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,922 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* stub-player/edit-mode.js - Visual editing logic
|
|
4
|
+
*
|
|
5
|
+
* Handles 'Edit Mode' where users can click elements in the course iframe
|
|
6
|
+
* to edit text content, tags, and classes directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { openEditorById } from './interaction-editor.js';
|
|
10
|
+
|
|
11
|
+
let editModeActive = false;
|
|
12
|
+
let currentToolbar = null;
|
|
13
|
+
|
|
14
|
+
export function createEditModeHandlers(context) {
|
|
15
|
+
const { getCmiData } = context;
|
|
16
|
+
|
|
17
|
+
// Initialize UI
|
|
18
|
+
const editModeBtn = document.getElementById('stub-player-edit-mode-btn');
|
|
19
|
+
|
|
20
|
+
if (!editModeBtn) return; // Not in live mode
|
|
21
|
+
|
|
22
|
+
// Toggle edit mode
|
|
23
|
+
editModeBtn.addEventListener('click', () => {
|
|
24
|
+
toggleEditMode();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function toggleEditMode() {
|
|
28
|
+
editModeActive = !editModeActive;
|
|
29
|
+
editModeBtn.classList.toggle('active', editModeActive);
|
|
30
|
+
|
|
31
|
+
const frame = document.getElementById('stub-player-course-frame');
|
|
32
|
+
|
|
33
|
+
// Clean up when exiting edit mode
|
|
34
|
+
if (!editModeActive) {
|
|
35
|
+
const doc = frame?.contentDocument;
|
|
36
|
+
if (doc) {
|
|
37
|
+
// Cancel any active contenteditable
|
|
38
|
+
const activeEditable = doc.querySelector('[contenteditable="true"]');
|
|
39
|
+
if (activeEditable) {
|
|
40
|
+
activeEditable.removeAttribute('contenteditable');
|
|
41
|
+
activeEditable.style.outline = '';
|
|
42
|
+
activeEditable.style.outlineOffset = '';
|
|
43
|
+
}
|
|
44
|
+
removeToolbar();
|
|
45
|
+
// Remove focus from everything
|
|
46
|
+
doc.activeElement?.blur();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setupIframeEditMode(frame);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
// Initial setup if frame already loaded or on load
|
|
56
|
+
const frame = document.getElementById('stub-player-course-frame');
|
|
57
|
+
if (frame) {
|
|
58
|
+
frame.addEventListener('load', () => setupIframeEditMode(frame));
|
|
59
|
+
setupIframeEditMode(frame);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Global keyboard shortcuts
|
|
63
|
+
document.addEventListener('keydown', async (e) => {
|
|
64
|
+
if (!editModeActive) return;
|
|
65
|
+
// Escape always exits edit mode from parent document
|
|
66
|
+
if (e.key === 'Escape') {
|
|
67
|
+
toggleEditMode();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// -------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
function getCurrentSlideFile() {
|
|
77
|
+
const cmiData = getCmiData();
|
|
78
|
+
try {
|
|
79
|
+
const suspendData = cmiData['cmi.suspend_data'];
|
|
80
|
+
if (suspendData) {
|
|
81
|
+
const parsed = typeof suspendData === 'string' ? JSON.parse(suspendData) : suspendData;
|
|
82
|
+
const currentSlide = parsed.currentSlide || parsed.slideId;
|
|
83
|
+
if (currentSlide) {
|
|
84
|
+
return currentSlide + '.js';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
} catch (_e) { }
|
|
88
|
+
// Fallback
|
|
89
|
+
const location = cmiData['cmi.location'];
|
|
90
|
+
if (location) {
|
|
91
|
+
return location + '.js';
|
|
92
|
+
}
|
|
93
|
+
return 'unknown.js';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getCurrentSlideId() {
|
|
97
|
+
return getCurrentSlideFile().replace(/\.js$/, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function setupIframeEditMode(frame) {
|
|
101
|
+
try {
|
|
102
|
+
const doc = frame.contentDocument || frame.contentWindow.document;
|
|
103
|
+
if (!doc) return;
|
|
104
|
+
|
|
105
|
+
// Toggle class on body
|
|
106
|
+
doc.body.classList.toggle('edit-mode-active', editModeActive);
|
|
107
|
+
|
|
108
|
+
// Inject styles
|
|
109
|
+
let styleEl = doc.getElementById('coursecode-edit-mode-styles');
|
|
110
|
+
if (editModeActive && !styleEl) {
|
|
111
|
+
styleEl = doc.createElement('style');
|
|
112
|
+
styleEl.id = 'coursecode-edit-mode-styles';
|
|
113
|
+
styleEl.textContent = `
|
|
114
|
+
[data-edit-path]:not(:has([data-edit-path])) {
|
|
115
|
+
outline: 2px dashed transparent;
|
|
116
|
+
outline-offset: 2px;
|
|
117
|
+
transition: outline-color 0.15s, background-color 0.15s;
|
|
118
|
+
cursor: text !important;
|
|
119
|
+
}
|
|
120
|
+
.edit-mode-active [data-edit-path]:not(:has([data-edit-path])):hover {
|
|
121
|
+
outline-color: #6366f1;
|
|
122
|
+
background-color: rgba(99, 102, 241, 0.1);
|
|
123
|
+
}
|
|
124
|
+
.edit-mode-active [data-interaction-id] {
|
|
125
|
+
cursor: pointer !important;
|
|
126
|
+
}
|
|
127
|
+
.edit-mode-active [data-interaction-id]:hover {
|
|
128
|
+
outline: 2px dashed #f59e0b;
|
|
129
|
+
outline-offset: 2px;
|
|
130
|
+
background-color: rgba(245, 158, 11, 0.08);
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
doc.head.appendChild(styleEl);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Attach listeners if not already attached (check a flag on doc?)
|
|
137
|
+
if (!doc._editHandlersAttached) {
|
|
138
|
+
doc._editHandlersAttached = true;
|
|
139
|
+
|
|
140
|
+
// Toolbar helper
|
|
141
|
+
injectToolbarStyles(doc);
|
|
142
|
+
|
|
143
|
+
// Selection change for toolbar state
|
|
144
|
+
doc.addEventListener('selectionchange', () => {
|
|
145
|
+
if (currentToolbar) {
|
|
146
|
+
updateToolbarState(currentToolbar, doc);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Main Click Handler
|
|
151
|
+
doc.addEventListener('click', (e) => handleIframeClick(e, doc, frame), true);
|
|
152
|
+
|
|
153
|
+
// Escape key in iframe: exit edit mode only if nothing is being edited
|
|
154
|
+
doc.addEventListener('keydown', (e) => {
|
|
155
|
+
if (!editModeActive) return;
|
|
156
|
+
if (e.key === 'Escape') {
|
|
157
|
+
const activeEditable = doc.querySelector('[contenteditable="true"]');
|
|
158
|
+
if (!activeEditable) {
|
|
159
|
+
toggleEditMode();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
} catch (_e) {
|
|
167
|
+
// Cannot access iframe (cross-origin?) or not ready
|
|
168
|
+
// console.warn('Could not access iframe for edit mode:', e);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Check if an element is a leaf editable (has no child elements with data-edit-path).
|
|
174
|
+
* This ensures we only select the deepest, most specific elements for editing.
|
|
175
|
+
*/
|
|
176
|
+
function isLeafEditable(el) {
|
|
177
|
+
return !el.querySelector('[data-edit-path]');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Find the deepest data-edit-path element under the click target.
|
|
182
|
+
* Walks from the clicked element upward, preferring the most specific (leaf) element.
|
|
183
|
+
*/
|
|
184
|
+
function findLeafEditable(target) {
|
|
185
|
+
// Start from the clicked element itself
|
|
186
|
+
let el = target;
|
|
187
|
+
while (el) {
|
|
188
|
+
if (el.hasAttribute?.('data-edit-path') && isLeafEditable(el)) {
|
|
189
|
+
return el;
|
|
190
|
+
}
|
|
191
|
+
el = el.parentElement;
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Determine if an element supports rich-text formatting (bold, italic, underline).
|
|
198
|
+
* Returns false for UI chrome elements (buttons, code, labels, etc.) where
|
|
199
|
+
* inline formatting tags would break component behavior or be meaningless.
|
|
200
|
+
*/
|
|
201
|
+
function isProseElement(el) {
|
|
202
|
+
const tag = el.tagName;
|
|
203
|
+
const NON_PROSE_TAGS = new Set(['BUTTON', 'A', 'PRE', 'CODE', 'LABEL', 'INPUT', 'SELECT', 'TEXTAREA', 'IMG']);
|
|
204
|
+
if (NON_PROSE_TAGS.has(tag)) return false;
|
|
205
|
+
if (el.hasAttribute('data-action')) return false;
|
|
206
|
+
if (el.closest('button, [data-action]')) return false;
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function handleIframeClick(e, doc, _frame) {
|
|
211
|
+
if (!editModeActive) return;
|
|
212
|
+
|
|
213
|
+
// 0. If clicking inside the currently active editable, let the browser handle
|
|
214
|
+
// cursor placement natively — don't finalize or restart the edit.
|
|
215
|
+
const activeEditable = doc.querySelector('[contenteditable="true"]');
|
|
216
|
+
if (activeEditable && activeEditable.contains(e.target)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 1. Finalize any active edit before starting a new one
|
|
221
|
+
let justFinalized = null;
|
|
222
|
+
if (activeEditable) {
|
|
223
|
+
justFinalized = activeEditable;
|
|
224
|
+
activeEditable.removeAttribute('contenteditable');
|
|
225
|
+
activeEditable.style.outline = '';
|
|
226
|
+
activeEditable.style.outlineOffset = '';
|
|
227
|
+
// Remove event listeners
|
|
228
|
+
if (activeEditable._editCleanup) {
|
|
229
|
+
activeEditable._editCleanup();
|
|
230
|
+
activeEditable._editCleanup = null;
|
|
231
|
+
}
|
|
232
|
+
// Persist changes to server
|
|
233
|
+
if (activeEditable._pendingSave) {
|
|
234
|
+
activeEditable._pendingSave();
|
|
235
|
+
activeEditable._pendingSave = null;
|
|
236
|
+
}
|
|
237
|
+
removeToolbar();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 1. Check for interaction elements - open interaction config modal
|
|
241
|
+
const interactionEl = e.target.closest('[data-interaction-id]');
|
|
242
|
+
if (interactionEl) {
|
|
243
|
+
e.preventDefault();
|
|
244
|
+
e.stopPropagation();
|
|
245
|
+
const interactionId = interactionEl.getAttribute('data-interaction-id');
|
|
246
|
+
const slideId = getCurrentSlideId();
|
|
247
|
+
openEditorById(interactionId, slideId);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// 2. Check for MCQ Choice editing
|
|
252
|
+
const choiceEl = e.target.closest('[data-editable-choice]');
|
|
253
|
+
if (choiceEl) {
|
|
254
|
+
handleChoiceEdit(e, choiceEl, doc);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 3. Check for general content editing - only target leaf elements
|
|
259
|
+
const editableEl = findLeafEditable(e.target);
|
|
260
|
+
if (!editableEl) return;
|
|
261
|
+
|
|
262
|
+
// If we just finalized this same element, don't re-enter (click = save & exit)
|
|
263
|
+
if (editableEl === justFinalized) return;
|
|
264
|
+
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
e.stopPropagation();
|
|
267
|
+
|
|
268
|
+
const editPath = editableEl.getAttribute('data-edit-path');
|
|
269
|
+
const originalHtml = editableEl.innerHTML;
|
|
270
|
+
const slideFile = getCurrentSlideFile();
|
|
271
|
+
|
|
272
|
+
// Make editable
|
|
273
|
+
editableEl.setAttribute('contenteditable', 'true');
|
|
274
|
+
editableEl.style.outline = '2px solid var(--accent-color, #3b82f6)';
|
|
275
|
+
editableEl.style.outlineOffset = '2px';
|
|
276
|
+
editableEl.focus();
|
|
277
|
+
|
|
278
|
+
// Place cursor at end (don't select all text)
|
|
279
|
+
const selection = doc.getSelection();
|
|
280
|
+
const range = doc.createRange();
|
|
281
|
+
range.selectNodeContents(editableEl);
|
|
282
|
+
range.collapse(false);
|
|
283
|
+
selection.removeAllRanges();
|
|
284
|
+
selection.addRange(range);
|
|
285
|
+
|
|
286
|
+
// Toolbar setup
|
|
287
|
+
removeToolbar();
|
|
288
|
+
const toolbarCallbacks = {
|
|
289
|
+
onTagSave: async (newTagString) => {
|
|
290
|
+
// Parse tag string logic...
|
|
291
|
+
let newTagName, newClasses = '';
|
|
292
|
+
const LT = String.fromCharCode(60);
|
|
293
|
+
const GT = String.fromCharCode(62);
|
|
294
|
+
const patternStr = '^' + LT + '(\\w+)([^' + GT + ']*)' + GT + '$';
|
|
295
|
+
const anglePattern = new RegExp(patternStr);
|
|
296
|
+
const fullMatch = newTagString.match(anglePattern);
|
|
297
|
+
|
|
298
|
+
if (fullMatch) {
|
|
299
|
+
newTagName = fullMatch[1];
|
|
300
|
+
const classMatch = fullMatch[2].match(/class="([^"]*)"/i);
|
|
301
|
+
newClasses = classMatch ? classMatch[1] : '';
|
|
302
|
+
} else {
|
|
303
|
+
const simpleMatch = newTagString.match(/^(\w+)$/);
|
|
304
|
+
if (simpleMatch) {
|
|
305
|
+
newTagName = simpleMatch[1];
|
|
306
|
+
} else {
|
|
307
|
+
return { error: 'Invalid format. Use <tagname> or just tagname' };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const response = await fetch('/__edit-tag', {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers: { 'Content-Type': 'application/json' },
|
|
315
|
+
body: JSON.stringify({
|
|
316
|
+
slideFile,
|
|
317
|
+
editPath,
|
|
318
|
+
newTag: newTagName,
|
|
319
|
+
newClasses
|
|
320
|
+
})
|
|
321
|
+
});
|
|
322
|
+
const result = await response.json();
|
|
323
|
+
if (!result.success) {
|
|
324
|
+
console.error('Tag edit failed:', result.error);
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
console.error('Tag edit error:', err);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
const proseMode = isProseElement(editableEl);
|
|
332
|
+
currentToolbar = createToolbar(doc, editableEl, toolbarCallbacks, { proseMode });
|
|
333
|
+
|
|
334
|
+
// Persist changes to server (no UI cleanup — may already be done by click handler)
|
|
335
|
+
let _saving = false;
|
|
336
|
+
const persistEdit = async () => {
|
|
337
|
+
if (_saving) return; // Guard against double-save
|
|
338
|
+
_saving = true;
|
|
339
|
+
normalizeExecCommandHtml(editableEl);
|
|
340
|
+
const newHtml = editableEl.innerHTML.trim();
|
|
341
|
+
|
|
342
|
+
if (newHtml === originalHtml.trim()) return;
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const response = await fetch('/__edit', {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
348
|
+
body: JSON.stringify({
|
|
349
|
+
slideFile,
|
|
350
|
+
editPath,
|
|
351
|
+
newText: newHtml,
|
|
352
|
+
isHtml: true
|
|
353
|
+
})
|
|
354
|
+
});
|
|
355
|
+
const result = await response.json();
|
|
356
|
+
if (!result.success) {
|
|
357
|
+
console.error('Edit failed:', result.error);
|
|
358
|
+
editableEl.innerHTML = originalHtml; // Revert
|
|
359
|
+
} else {
|
|
360
|
+
// Success — edit saved
|
|
361
|
+
}
|
|
362
|
+
} catch (err) {
|
|
363
|
+
console.error('Edit error:', err);
|
|
364
|
+
editableEl.innerHTML = originalHtml;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// Full save: cleanup UI + persist
|
|
369
|
+
const saveEdit = () => {
|
|
370
|
+
cleanup();
|
|
371
|
+
editableEl.removeAttribute('contenteditable');
|
|
372
|
+
editableEl.style.outline = '';
|
|
373
|
+
editableEl.style.outlineOffset = '';
|
|
374
|
+
editableEl._pendingSave = null;
|
|
375
|
+
removeToolbar();
|
|
376
|
+
persistEdit();
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const cancelEdit = () => {
|
|
380
|
+
cleanup();
|
|
381
|
+
editableEl.innerHTML = originalHtml;
|
|
382
|
+
editableEl.removeAttribute('contenteditable');
|
|
383
|
+
editableEl.style.outline = '';
|
|
384
|
+
editableEl.style.outlineOffset = '';
|
|
385
|
+
editableEl._pendingSave = null;
|
|
386
|
+
removeToolbar();
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// Store persist function so click handler can invoke it during transitions
|
|
390
|
+
editableEl._pendingSave = persistEdit;
|
|
391
|
+
|
|
392
|
+
const handleBlur = (ev) => {
|
|
393
|
+
// If clicking on toolbar, don't save yet
|
|
394
|
+
if (currentToolbar && currentToolbar.contains(ev.relatedTarget)) return;
|
|
395
|
+
setTimeout(() => {
|
|
396
|
+
// If already finalized by click handler, skip
|
|
397
|
+
if (!editableEl.hasAttribute('contenteditable')) return;
|
|
398
|
+
saveEdit();
|
|
399
|
+
}, 100);
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
const handleKeydown = (ev) => {
|
|
403
|
+
if (ev.key === 'Escape') {
|
|
404
|
+
ev.preventDefault();
|
|
405
|
+
ev.stopPropagation(); // Don't bubble to doc handler — 2nd Escape exits edit mode
|
|
406
|
+
cancelEdit();
|
|
407
|
+
} else if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
408
|
+
ev.preventDefault();
|
|
409
|
+
saveEdit();
|
|
410
|
+
} else {
|
|
411
|
+
handleFormattingShortcuts(ev, doc);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Paste sanitization: strip rich formatting, keep only plain text with line breaks
|
|
416
|
+
const handlePaste = (ev) => {
|
|
417
|
+
ev.preventDefault();
|
|
418
|
+
const text = ev.clipboardData?.getData('text/plain') || '';
|
|
419
|
+
const lines = text.split(/\r?\n/);
|
|
420
|
+
const selection = doc.getSelection();
|
|
421
|
+
if (!selection.rangeCount) return;
|
|
422
|
+
selection.deleteFromDocument();
|
|
423
|
+
const frag = doc.createDocumentFragment();
|
|
424
|
+
lines.forEach((line, i) => {
|
|
425
|
+
frag.appendChild(doc.createTextNode(line));
|
|
426
|
+
if (i < lines.length - 1) frag.appendChild(doc.createElement('br'));
|
|
427
|
+
});
|
|
428
|
+
selection.getRangeAt(0).insertNode(frag);
|
|
429
|
+
selection.collapseToEnd();
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
function cleanup() {
|
|
433
|
+
editableEl.removeEventListener('blur', handleBlur);
|
|
434
|
+
editableEl.removeEventListener('keydown', handleKeydown);
|
|
435
|
+
editableEl.removeEventListener('paste', handlePaste);
|
|
436
|
+
editableEl._editCleanup = null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
editableEl._editCleanup = cleanup;
|
|
440
|
+
editableEl.addEventListener('blur', handleBlur);
|
|
441
|
+
editableEl.addEventListener('keydown', handleKeydown);
|
|
442
|
+
editableEl.addEventListener('paste', handlePaste);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function handleFormattingShortcuts(e, doc) {
|
|
446
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
447
|
+
if (!['b', 'i', 'u'].includes(e.key)) return;
|
|
448
|
+
|
|
449
|
+
e.preventDefault();
|
|
450
|
+
// Suppress formatting shortcuts on non-prose elements
|
|
451
|
+
const activeEditable = doc.querySelector('[contenteditable="true"]');
|
|
452
|
+
if (activeEditable && !isProseElement(activeEditable)) return;
|
|
453
|
+
|
|
454
|
+
const cmd = { b: 'bold', i: 'italic', u: 'underline' }[e.key];
|
|
455
|
+
doc.execCommand(cmd, false, null);
|
|
456
|
+
if (currentToolbar) updateToolbarState(currentToolbar, doc);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function handleChoiceEdit(e, choiceEl, _doc) {
|
|
460
|
+
if (!editModeActive) return;
|
|
461
|
+
if (choiceEl.hasAttribute('contenteditable')) return;
|
|
462
|
+
|
|
463
|
+
e.preventDefault();
|
|
464
|
+
e.stopPropagation();
|
|
465
|
+
|
|
466
|
+
const interactionId = choiceEl.getAttribute('data-edit-for-interaction');
|
|
467
|
+
const choiceIndex = choiceEl.getAttribute('data-choice-index');
|
|
468
|
+
const originalText = choiceEl.textContent;
|
|
469
|
+
const slideId = getCurrentSlideId();
|
|
470
|
+
|
|
471
|
+
choiceEl.setAttribute('contenteditable', 'true');
|
|
472
|
+
choiceEl.style.outline = '2px solid var(--accent-color, #3b82f6)';
|
|
473
|
+
choiceEl.style.outlineOffset = '2px';
|
|
474
|
+
choiceEl.style.minWidth = '100px';
|
|
475
|
+
choiceEl.focus();
|
|
476
|
+
|
|
477
|
+
const saveChoiceEdit = async () => {
|
|
478
|
+
const newText = choiceEl.textContent.trim();
|
|
479
|
+
if (newText === originalText) {
|
|
480
|
+
cleanupChoice();
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const response = await fetch('/__edit-interaction', {
|
|
486
|
+
method: 'POST',
|
|
487
|
+
headers: { 'Content-Type': 'application/json' },
|
|
488
|
+
body: JSON.stringify({
|
|
489
|
+
slideId,
|
|
490
|
+
interactionId,
|
|
491
|
+
field: `choices[${choiceIndex}].text`,
|
|
492
|
+
value: newText
|
|
493
|
+
})
|
|
494
|
+
});
|
|
495
|
+
if (!response.ok) {
|
|
496
|
+
const result = await response.json();
|
|
497
|
+
console.error('MCQ edit failed:', result.error);
|
|
498
|
+
choiceEl.textContent = originalText;
|
|
499
|
+
}
|
|
500
|
+
} catch (err) {
|
|
501
|
+
console.error('MCQ edit error:', err);
|
|
502
|
+
choiceEl.textContent = originalText;
|
|
503
|
+
}
|
|
504
|
+
cleanupChoice();
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const cleanupChoice = () => {
|
|
508
|
+
choiceEl.removeAttribute('contenteditable');
|
|
509
|
+
choiceEl.style.outline = '';
|
|
510
|
+
choiceEl.style.outlineOffset = '';
|
|
511
|
+
choiceEl.style.minWidth = '';
|
|
512
|
+
choiceEl.removeEventListener('blur', handleChoiceBlur);
|
|
513
|
+
choiceEl.removeEventListener('keydown', handleChoiceKeydown);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const handleChoiceBlur = () => saveChoiceEdit();
|
|
517
|
+
const handleChoiceKeydown = (ev) => {
|
|
518
|
+
if (ev.key === 'Escape') {
|
|
519
|
+
ev.preventDefault();
|
|
520
|
+
choiceEl.textContent = originalText;
|
|
521
|
+
cleanupChoice();
|
|
522
|
+
} else if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
523
|
+
ev.preventDefault();
|
|
524
|
+
saveChoiceEdit();
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
choiceEl.addEventListener('blur', handleChoiceBlur);
|
|
529
|
+
choiceEl.addEventListener('keydown', handleChoiceKeydown);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
// -----------------------------------------------------------------------------
|
|
536
|
+
// HTML Normalization (clean up execCommand artifacts)
|
|
537
|
+
// -----------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Browsers' execCommand leaves behind messy HTML when toggling formatting:
|
|
541
|
+
* - <span style="font-weight: normal;"> inside an already-bold parent
|
|
542
|
+
* - <span style="font-weight: bold;"> instead of <strong>
|
|
543
|
+
* - <span style="text-decoration: underline;"> instead of <u>
|
|
544
|
+
* - Combined bold+italic in a single span
|
|
545
|
+
* - Empty <strong></strong> tags after un-bolding
|
|
546
|
+
* - <b> instead of <strong>, <i> instead of <em>
|
|
547
|
+
* - Adjacent <strong>foo</strong><strong>bar</strong> that should merge
|
|
548
|
+
* - Fragmented text nodes from DOM manipulation
|
|
549
|
+
*
|
|
550
|
+
* This normalizes the HTML before saving to produce clean, semantic output
|
|
551
|
+
* that aligns with the framework's CSS (e.g. <strong> not inline styles).
|
|
552
|
+
*/
|
|
553
|
+
function normalizeExecCommandHtml(container) {
|
|
554
|
+
const win = container.ownerDocument.defaultView || window;
|
|
555
|
+
const doc = container.ownerDocument;
|
|
556
|
+
|
|
557
|
+
// Check if the container itself provides bold/italic context
|
|
558
|
+
const inheritsBold = container.tagName === 'B' || container.tagName === 'STRONG'
|
|
559
|
+
|| container.classList?.contains('font-bold')
|
|
560
|
+
|| win.getComputedStyle(container).fontWeight >= 700;
|
|
561
|
+
|
|
562
|
+
const inheritsItalic = container.tagName === 'I' || container.tagName === 'EM'
|
|
563
|
+
|| container.classList?.contains('italic')
|
|
564
|
+
|| win.getComputedStyle(container).fontStyle === 'italic';
|
|
565
|
+
|
|
566
|
+
// ── Pass 1: Normalize <b> → <strong>, <i> → <em> ──
|
|
567
|
+
const TAG_MAP = { B: 'strong', I: 'em' };
|
|
568
|
+
for (const [oldTag, newTag] of Object.entries(TAG_MAP)) {
|
|
569
|
+
for (const el of [...container.querySelectorAll(oldTag)]) {
|
|
570
|
+
const replacement = doc.createElement(newTag);
|
|
571
|
+
// Copy attributes (rare, but defensive)
|
|
572
|
+
for (const attr of el.attributes) {
|
|
573
|
+
replacement.setAttribute(attr.name, attr.value);
|
|
574
|
+
}
|
|
575
|
+
replacement.append(...el.childNodes);
|
|
576
|
+
el.replaceWith(replacement);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── Pass 2: Convert styled spans to semantic elements (bottom-up) ──
|
|
581
|
+
const spans = [...container.querySelectorAll('span[style]')].reverse();
|
|
582
|
+
|
|
583
|
+
for (const span of spans) {
|
|
584
|
+
const weight = span.style.fontWeight;
|
|
585
|
+
const fontStyle = span.style.fontStyle;
|
|
586
|
+
const textDecor = span.style.textDecoration;
|
|
587
|
+
|
|
588
|
+
// 2a. Remove redundant "normal" overrides when parent already provides formatting
|
|
589
|
+
if (weight === 'normal' && (inheritsBold || span.closest('strong, b'))) {
|
|
590
|
+
span.style.removeProperty('font-weight');
|
|
591
|
+
}
|
|
592
|
+
if (fontStyle === 'normal' && (inheritsItalic || span.closest('em, i'))) {
|
|
593
|
+
span.style.removeProperty('font-style');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const isBold = weight === 'bold' || weight === '700';
|
|
597
|
+
const isItalic = fontStyle === 'italic';
|
|
598
|
+
const isUnderline = textDecor?.includes('underline');
|
|
599
|
+
|
|
600
|
+
// 2b. Combined bold+italic → nested <strong><em>
|
|
601
|
+
if (isBold && isItalic) {
|
|
602
|
+
span.style.removeProperty('font-weight');
|
|
603
|
+
span.style.removeProperty('font-style');
|
|
604
|
+
const hasRemainingStyles = span.getAttribute('style')?.trim();
|
|
605
|
+
const strong = doc.createElement('strong');
|
|
606
|
+
const em = doc.createElement('em');
|
|
607
|
+
em.append(...span.childNodes);
|
|
608
|
+
strong.appendChild(em);
|
|
609
|
+
if (hasRemainingStyles) {
|
|
610
|
+
span.innerHTML = '';
|
|
611
|
+
span.appendChild(strong);
|
|
612
|
+
} else {
|
|
613
|
+
span.replaceWith(strong);
|
|
614
|
+
}
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// 2c. Bold span → <strong>
|
|
619
|
+
if (isBold) {
|
|
620
|
+
span.style.removeProperty('font-weight');
|
|
621
|
+
if (!span.getAttribute('style')?.trim()) {
|
|
622
|
+
const el = doc.createElement('strong');
|
|
623
|
+
el.append(...span.childNodes);
|
|
624
|
+
span.replaceWith(el);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 2d. Italic span → <em>
|
|
630
|
+
if (isItalic) {
|
|
631
|
+
span.style.removeProperty('font-style');
|
|
632
|
+
if (!span.getAttribute('style')?.trim()) {
|
|
633
|
+
const el = doc.createElement('em');
|
|
634
|
+
el.append(...span.childNodes);
|
|
635
|
+
span.replaceWith(el);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// 2e. Underline span → <u>
|
|
641
|
+
if (isUnderline) {
|
|
642
|
+
span.style.removeProperty('text-decoration');
|
|
643
|
+
if (!span.getAttribute('style')?.trim()) {
|
|
644
|
+
const el = doc.createElement('u');
|
|
645
|
+
el.append(...span.childNodes);
|
|
646
|
+
span.replaceWith(el);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// 2f. Unwrap spans with no remaining meaningful attributes
|
|
652
|
+
if (!span.getAttribute('style')?.trim()) span.removeAttribute('style');
|
|
653
|
+
if (!span.attributes.length) {
|
|
654
|
+
span.replaceWith(...span.childNodes);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Pass 3: Normalize <div> line breaks (Chrome inserts <div> for Enter) ──
|
|
659
|
+
for (const div of [...container.querySelectorAll('div')]) {
|
|
660
|
+
// Only unwrap divs that execCommand inserted (no classes, no id, no data attrs)
|
|
661
|
+
if (div.attributes.length > 0) continue;
|
|
662
|
+
const br = doc.createElement('br');
|
|
663
|
+
div.before(br);
|
|
664
|
+
div.replaceWith(...div.childNodes);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ── Pass 4: Remove empty semantic tags (left after un-formatting) ──
|
|
668
|
+
for (const tag of ['strong', 'em', 'u', 'b', 'i']) {
|
|
669
|
+
for (const el of [...container.querySelectorAll(tag)]) {
|
|
670
|
+
if (!el.textContent.trim() && !el.querySelector('img, br')) {
|
|
671
|
+
el.replaceWith(...el.childNodes);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ── Pass 5: Merge adjacent same-tag siblings ──
|
|
677
|
+
// e.g. <strong>foo</strong><strong>bar</strong> → <strong>foobar</strong>
|
|
678
|
+
for (const tag of ['strong', 'em', 'u']) {
|
|
679
|
+
for (const el of [...container.querySelectorAll(tag)]) {
|
|
680
|
+
while (el.nextSibling && el.nextSibling.nodeName === el.nodeName) {
|
|
681
|
+
const sibling = el.nextSibling;
|
|
682
|
+
el.append(...sibling.childNodes);
|
|
683
|
+
sibling.remove();
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// ── Pass 6: Clean up → regular spaces ──
|
|
689
|
+
const walker = doc.createTreeWalker(container, NodeFilter.SHOW_TEXT);
|
|
690
|
+
let node;
|
|
691
|
+
while ((node = walker.nextNode())) {
|
|
692
|
+
if (node.nodeValue.includes('\u00A0')) {
|
|
693
|
+
node.nodeValue = node.nodeValue.replace(/\u00A0/g, ' ');
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Merge fragmented text nodes
|
|
698
|
+
container.normalize();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// -----------------------------------------------------------------------------
|
|
702
|
+
// Toolbar Logic (Extracted)
|
|
703
|
+
// -----------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
function injectToolbarStyles(iframeDoc) {
|
|
706
|
+
if (iframeDoc.getElementById('stub-player-toolbar-styles')) return;
|
|
707
|
+
const style = iframeDoc.createElement('style');
|
|
708
|
+
style.id = 'stub-player-toolbar-styles';
|
|
709
|
+
style.textContent = `
|
|
710
|
+
.stub-player-format-toolbar {
|
|
711
|
+
position: absolute;
|
|
712
|
+
display: flex;
|
|
713
|
+
flex-wrap: nowrap;
|
|
714
|
+
align-items: center;
|
|
715
|
+
gap: 4px;
|
|
716
|
+
padding: 4px;
|
|
717
|
+
background: #1a1a2e;
|
|
718
|
+
border: 1px solid #3a3a5c;
|
|
719
|
+
border-radius: 6px;
|
|
720
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
721
|
+
z-index: 999999;
|
|
722
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
723
|
+
}
|
|
724
|
+
.stub-player-format-toolbar button {
|
|
725
|
+
width: 28px;
|
|
726
|
+
height: 28px;
|
|
727
|
+
border: none;
|
|
728
|
+
background: #4a6fa5;
|
|
729
|
+
color: #fff;
|
|
730
|
+
border-radius: 4px;
|
|
731
|
+
cursor: pointer;
|
|
732
|
+
font-size: 13px;
|
|
733
|
+
font-weight: 600;
|
|
734
|
+
display: flex;
|
|
735
|
+
align-items: center;
|
|
736
|
+
justify-content: center;
|
|
737
|
+
transition: all 0.15s;
|
|
738
|
+
flex-shrink: 0;
|
|
739
|
+
}
|
|
740
|
+
.stub-player-format-toolbar button:hover:not(:disabled) { background: #5a5a9c; color: #fff; }
|
|
741
|
+
.stub-player-format-toolbar button.active { background: #6366f1; color: #fff; }
|
|
742
|
+
.stub-player-format-toolbar button:disabled {
|
|
743
|
+
opacity: 0.35;
|
|
744
|
+
cursor: not-allowed;
|
|
745
|
+
background: #3a3a5c;
|
|
746
|
+
}
|
|
747
|
+
.stub-player-format-toolbar .separator { width: 1px; background: #3a3a5c; margin: 4px 2px; }
|
|
748
|
+
.stub-player-format-toolbar.tag-mode { gap: 4px; align-items: center; }
|
|
749
|
+
.stub-player-format-toolbar .tag-input {
|
|
750
|
+
background: #252542;
|
|
751
|
+
border: 1px solid #3a3a5c;
|
|
752
|
+
border-radius: 4px;
|
|
753
|
+
color: #e0e0e0;
|
|
754
|
+
padding: 6px 10px;
|
|
755
|
+
font-family: 'SF Mono', Consolas, monospace;
|
|
756
|
+
font-size: 12px;
|
|
757
|
+
line-height: 1.4;
|
|
758
|
+
min-width: 280px;
|
|
759
|
+
max-width: 500px;
|
|
760
|
+
min-height: 0;
|
|
761
|
+
height: auto;
|
|
762
|
+
resize: vertical;
|
|
763
|
+
field-sizing: content;
|
|
764
|
+
}
|
|
765
|
+
.stub-player-format-toolbar .tag-input:focus { outline: none; border-color: #6366f1; }
|
|
766
|
+
.stub-player-format-toolbar .tag-save-btn { background: #22c55e; color: #fff; padding: 0 12px; width: auto; height: auto; align-self: stretch; }
|
|
767
|
+
.stub-player-format-toolbar .tag-save-btn:hover { background: #16a34a; }
|
|
768
|
+
.stub-player-format-toolbar .tag-cancel-btn { background: #6b7280; padding: 0 10px; height: auto; align-self: stretch; }
|
|
769
|
+
.stub-player-format-toolbar .tag-cancel-btn:hover { background: #4b5563; }
|
|
770
|
+
.stub-player-format-toolbar .tag-edit-btn { width: auto; padding: 0 8px; font-size: 12px; white-space: nowrap; }
|
|
771
|
+
`;
|
|
772
|
+
iframeDoc.head.appendChild(style);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function createToolbar(iframeDoc, editableEl, callbacks = {}, options = {}) {
|
|
776
|
+
injectToolbarStyles(iframeDoc);
|
|
777
|
+
|
|
778
|
+
const { proseMode = true } = options;
|
|
779
|
+
const toolbar = iframeDoc.createElement('div');
|
|
780
|
+
toolbar.className = 'stub-player-format-toolbar';
|
|
781
|
+
toolbar._editableEl = editableEl;
|
|
782
|
+
toolbar._callbacks = callbacks;
|
|
783
|
+
toolbar._tagMode = false;
|
|
784
|
+
toolbar._proseMode = proseMode;
|
|
785
|
+
|
|
786
|
+
const getOpeningTag = () => {
|
|
787
|
+
const tag = editableEl.tagName.toLowerCase();
|
|
788
|
+
const classes = editableEl.className ? ` class="${editableEl.className}"` : '';
|
|
789
|
+
const id = editableEl.id ? ` id="${editableEl.id}"` : '';
|
|
790
|
+
return `<${tag}${id}${classes}>`;
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const disabledAttr = proseMode ? '' : ' disabled';
|
|
794
|
+
const disabledTitle = proseMode ? '' : ' — text only';
|
|
795
|
+
|
|
796
|
+
const renderFormatMode = () => {
|
|
797
|
+
toolbar._tagMode = false;
|
|
798
|
+
toolbar.classList.remove('tag-mode');
|
|
799
|
+
toolbar.innerHTML = `
|
|
800
|
+
<button data-cmd="bold" title="Bold (Ctrl+B)${disabledTitle}"${disabledAttr}><strong>B</strong></button>
|
|
801
|
+
<button data-cmd="italic" title="Italic (Ctrl+I)${disabledTitle}"${disabledAttr}><em>I</em></button>
|
|
802
|
+
<button data-cmd="underline" title="Underline (Ctrl+U)${disabledTitle}"${disabledAttr}><u>U</u></button>
|
|
803
|
+
<div class="separator"></div>
|
|
804
|
+
<button data-action="tag-edit" class="tag-edit-btn" title="${proseMode ? 'Edit Tag/Classes' : 'Tag editing unavailable — text only'}"${disabledAttr}></></button>
|
|
805
|
+
`;
|
|
806
|
+
|
|
807
|
+
toolbar.querySelectorAll('button[data-cmd]:not(:disabled)').forEach(btn => {
|
|
808
|
+
btn.addEventListener('mousedown', (e) => {
|
|
809
|
+
e.preventDefault();
|
|
810
|
+
iframeDoc.execCommand(btn.dataset.cmd, false, null);
|
|
811
|
+
updateToolbarState(toolbar, iframeDoc);
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
const tagEditBtn = toolbar.querySelector('[data-action="tag-edit"]:not(:disabled)');
|
|
816
|
+
if (tagEditBtn) {
|
|
817
|
+
tagEditBtn.addEventListener('mousedown', (e) => {
|
|
818
|
+
e.preventDefault();
|
|
819
|
+
renderTagMode();
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
updateToolbarState(toolbar, iframeDoc);
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const renderTagMode = () => {
|
|
827
|
+
toolbar._tagMode = true;
|
|
828
|
+
toolbar.classList.add('tag-mode');
|
|
829
|
+
const currentTag = getOpeningTag();
|
|
830
|
+
const escapedTag = currentTag.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
831
|
+
toolbar.innerHTML = `
|
|
832
|
+
<textarea class="tag-input" rows="1" title="Edit opening tag">${escapedTag}</textarea>
|
|
833
|
+
<button class="tag-save-btn" title="Save (Ctrl+Enter)">Save</button>
|
|
834
|
+
<button class="tag-cancel-btn" title="Cancel (Esc)">×</button>
|
|
835
|
+
`;
|
|
836
|
+
|
|
837
|
+
const input = toolbar.querySelector('.tag-input');
|
|
838
|
+
input.focus();
|
|
839
|
+
// Place cursor at end, don't select
|
|
840
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
841
|
+
|
|
842
|
+
// Decode HTML entities back for the actual value
|
|
843
|
+
input.value = currentTag;
|
|
844
|
+
|
|
845
|
+
const saveTagEdit = async () => {
|
|
846
|
+
const newTag = input.value.trim();
|
|
847
|
+
if (callbacks.onTagSave) {
|
|
848
|
+
try {
|
|
849
|
+
const result = await callbacks.onTagSave(newTag);
|
|
850
|
+
if (result && result.error) {
|
|
851
|
+
input.style.borderColor = '#ff4444';
|
|
852
|
+
input.title = result.error;
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
} catch (_err) {
|
|
856
|
+
input.style.borderColor = '#ff4444';
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
renderFormatMode();
|
|
861
|
+
editableEl.focus();
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
const cancelTagEdit = () => {
|
|
865
|
+
renderFormatMode();
|
|
866
|
+
editableEl.focus();
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
toolbar.querySelector('.tag-save-btn').addEventListener('mousedown', (e) => {
|
|
870
|
+
e.preventDefault();
|
|
871
|
+
saveTagEdit();
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
toolbar.querySelector('.tag-cancel-btn').addEventListener('mousedown', (e) => {
|
|
875
|
+
e.preventDefault();
|
|
876
|
+
cancelTagEdit();
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
input.addEventListener('keydown', (e) => {
|
|
880
|
+
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
881
|
+
e.preventDefault();
|
|
882
|
+
saveTagEdit();
|
|
883
|
+
} else if (e.key === 'Escape') {
|
|
884
|
+
e.preventDefault();
|
|
885
|
+
cancelTagEdit();
|
|
886
|
+
}
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
input.addEventListener('blur', (e) => {
|
|
890
|
+
if (toolbar.contains(e.relatedTarget)) return;
|
|
891
|
+
setTimeout(() => {
|
|
892
|
+
if (toolbar._tagMode && toolbar.isConnected) renderFormatMode();
|
|
893
|
+
}, 100);
|
|
894
|
+
});
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const rect = editableEl.getBoundingClientRect();
|
|
898
|
+
const scrollTop = iframeDoc.documentElement.scrollTop || iframeDoc.body.scrollTop;
|
|
899
|
+
toolbar.style.left = rect.left + 'px';
|
|
900
|
+
toolbar.style.top = (rect.top + scrollTop - 44) + 'px';
|
|
901
|
+
|
|
902
|
+
renderFormatMode();
|
|
903
|
+
iframeDoc.body.appendChild(toolbar);
|
|
904
|
+
return toolbar;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function updateToolbarState(toolbar, iframeDoc) {
|
|
908
|
+
if (toolbar._tagMode) return;
|
|
909
|
+
const boldBtn = toolbar.querySelector('[data-cmd="bold"]');
|
|
910
|
+
const italicBtn = toolbar.querySelector('[data-cmd="italic"]');
|
|
911
|
+
const underlineBtn = toolbar.querySelector('[data-cmd="underline"]');
|
|
912
|
+
if (boldBtn) boldBtn.classList.toggle('active', iframeDoc.queryCommandState('bold'));
|
|
913
|
+
if (italicBtn) italicBtn.classList.toggle('active', iframeDoc.queryCommandState('italic'));
|
|
914
|
+
if (underlineBtn) underlineBtn.classList.toggle('active', iframeDoc.queryCommandState('underline'));
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function removeToolbar() {
|
|
918
|
+
if (currentToolbar) {
|
|
919
|
+
currentToolbar.remove();
|
|
920
|
+
currentToolbar = null;
|
|
921
|
+
}
|
|
922
|
+
}
|