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.
Files changed (362) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +322 -0
  3. package/THIRD_PARTY_NOTICES.md +22 -0
  4. package/bin/cli.js +331 -0
  5. package/framework/assets/logo-coursecode-black.svg +14 -0
  6. package/framework/assets/logo-coursecode-white.svg +14 -0
  7. package/framework/assets/logo-coursecode.svg +14 -0
  8. package/framework/css/01-base.css +160 -0
  9. package/framework/css/02-layout.css +499 -0
  10. package/framework/css/accessibility.css +834 -0
  11. package/framework/css/components/accordions.css +710 -0
  12. package/framework/css/components/assessments.css +520 -0
  13. package/framework/css/components/audio-player.css +570 -0
  14. package/framework/css/components/badges.css +80 -0
  15. package/framework/css/components/breadcrumbs.css +87 -0
  16. package/framework/css/components/buttons.css +707 -0
  17. package/framework/css/components/callouts.css +1280 -0
  18. package/framework/css/components/cards.css +475 -0
  19. package/framework/css/components/carousel.css +193 -0
  20. package/framework/css/components/checkbox-group.css +123 -0
  21. package/framework/css/components/checklist.css +203 -0
  22. package/framework/css/components/collapse.css +96 -0
  23. package/framework/css/components/comparison.css +33 -0
  24. package/framework/css/components/content-image.css +36 -0
  25. package/framework/css/components/document-gallery.css +425 -0
  26. package/framework/css/components/dropdown.css +115 -0
  27. package/framework/css/components/embed-frame.css +142 -0
  28. package/framework/css/components/engagement.css +412 -0
  29. package/framework/css/components/features.css +35 -0
  30. package/framework/css/components/flip-cards.css +253 -0
  31. package/framework/css/components/footer.css +353 -0
  32. package/framework/css/components/forms.css +294 -0
  33. package/framework/css/components/hero.css +216 -0
  34. package/framework/css/components/images.css +528 -0
  35. package/framework/css/components/interactive-timeline.css +274 -0
  36. package/framework/css/components/intro-cards.css +30 -0
  37. package/framework/css/components/lightbox.css +666 -0
  38. package/framework/css/components/loading.css +65 -0
  39. package/framework/css/components/modals.css +235 -0
  40. package/framework/css/components/notifications.css +107 -0
  41. package/framework/css/components/quote.css +150 -0
  42. package/framework/css/components/sidebar.css +684 -0
  43. package/framework/css/components/slide-header.css +52 -0
  44. package/framework/css/components/spinner.css +62 -0
  45. package/framework/css/components/stats.css +44 -0
  46. package/framework/css/components/steps.css +232 -0
  47. package/framework/css/components/tables.css +90 -0
  48. package/framework/css/components/tabs.css +347 -0
  49. package/framework/css/components/timeline.css +154 -0
  50. package/framework/css/components/toggle.css +95 -0
  51. package/framework/css/components/tooltip.css +226 -0
  52. package/framework/css/components/video-player.css +438 -0
  53. package/framework/css/design-tokens.css +707 -0
  54. package/framework/css/framework.css +86 -0
  55. package/framework/css/interactions/accessibility.css +75 -0
  56. package/framework/css/interactions/base.css +92 -0
  57. package/framework/css/interactions/drag-drop.css +295 -0
  58. package/framework/css/interactions/fill-in-the-blank.css +236 -0
  59. package/framework/css/interactions/hotspots.css +69 -0
  60. package/framework/css/interactions/index.css +45 -0
  61. package/framework/css/interactions/interactive-image.css +359 -0
  62. package/framework/css/interactions/likert.css +126 -0
  63. package/framework/css/interactions/matching.css +354 -0
  64. package/framework/css/interactions/numeric-input.css +78 -0
  65. package/framework/css/interactions/sequencing.css +378 -0
  66. package/framework/css/interactions/true-false.css +177 -0
  67. package/framework/css/layouts/article.css +258 -0
  68. package/framework/css/layouts/base.css +30 -0
  69. package/framework/css/layouts/canvas.css +38 -0
  70. package/framework/css/layouts/focused.css +236 -0
  71. package/framework/css/layouts/index.css +29 -0
  72. package/framework/css/layouts/presentation.css +191 -0
  73. package/framework/css/layouts/traditional.css +52 -0
  74. package/framework/css/responsive.css +439 -0
  75. package/framework/css/utilities/accessibility-utils.css +59 -0
  76. package/framework/css/utilities/animations.css +419 -0
  77. package/framework/css/utilities/borders.css +72 -0
  78. package/framework/css/utilities/colors.css +76 -0
  79. package/framework/css/utilities/container.css +46 -0
  80. package/framework/css/utilities/decorative.css +442 -0
  81. package/framework/css/utilities/display.css +257 -0
  82. package/framework/css/utilities/flexbox.css +80 -0
  83. package/framework/css/utilities/grid.css +69 -0
  84. package/framework/css/utilities/icons.css +534 -0
  85. package/framework/css/utilities/lists.css +190 -0
  86. package/framework/css/utilities/spacing.css +167 -0
  87. package/framework/css/utilities/tables.css +81 -0
  88. package/framework/css/utilities/typography.css +159 -0
  89. package/framework/css/utilities/visibility.css +117 -0
  90. package/framework/docs/COURSE_AUTHORING_GUIDE.md +1773 -0
  91. package/framework/docs/COURSE_OUTLINE_GUIDE.md +725 -0
  92. package/framework/docs/COURSE_OUTLINE_TEMPLATE.md +161 -0
  93. package/framework/docs/DATA_MODEL.md +409 -0
  94. package/framework/docs/FRAMEWORK_GUIDE.md +1088 -0
  95. package/framework/docs/USER_GUIDE.md +583 -0
  96. package/framework/docs/examples/cloudflare-channel-relay.js +169 -0
  97. package/framework/docs/examples/cloudflare-data-worker.js +102 -0
  98. package/framework/docs/examples/cloudflare-error-worker.js +228 -0
  99. package/framework/index.html +175 -0
  100. package/framework/js/app/AppActions.js +410 -0
  101. package/framework/js/app/AppState.js +225 -0
  102. package/framework/js/app/AppUI.js +616 -0
  103. package/framework/js/assessment/AssessmentActions.js +615 -0
  104. package/framework/js/assessment/AssessmentFactory.js +471 -0
  105. package/framework/js/assessment/AssessmentState.js +322 -0
  106. package/framework/js/assessment/AssessmentUI.js +451 -0
  107. package/framework/js/automation/api-engagement.js +196 -0
  108. package/framework/js/automation/api-interactions.js +167 -0
  109. package/framework/js/automation/api.js +242 -0
  110. package/framework/js/automation/index.js +41 -0
  111. package/framework/js/components/interactions/drag-drop.js +884 -0
  112. package/framework/js/components/interactions/fill-in.js +535 -0
  113. package/framework/js/components/interactions/hotspot.js +702 -0
  114. package/framework/js/components/interactions/interaction-base.js +511 -0
  115. package/framework/js/components/interactions/likert.js +301 -0
  116. package/framework/js/components/interactions/matching.js +699 -0
  117. package/framework/js/components/interactions/multiple-choice.js +377 -0
  118. package/framework/js/components/interactions/numeric.js +271 -0
  119. package/framework/js/components/interactions/sequencing.js +423 -0
  120. package/framework/js/components/interactions/true-false.js +241 -0
  121. package/framework/js/components/ui-components/accordion.js +442 -0
  122. package/framework/js/components/ui-components/alert.js +88 -0
  123. package/framework/js/components/ui-components/audio-player.js +1193 -0
  124. package/framework/js/components/ui-components/callout.js +121 -0
  125. package/framework/js/components/ui-components/carousel.js +145 -0
  126. package/framework/js/components/ui-components/checkbox-group.js +87 -0
  127. package/framework/js/components/ui-components/checklist.js +40 -0
  128. package/framework/js/components/ui-components/collapse.js +114 -0
  129. package/framework/js/components/ui-components/comparison.js +30 -0
  130. package/framework/js/components/ui-components/conditional-display.js +150 -0
  131. package/framework/js/components/ui-components/content-image.js +41 -0
  132. package/framework/js/components/ui-components/dropdown.js +262 -0
  133. package/framework/js/components/ui-components/embed-frame.js +274 -0
  134. package/framework/js/components/ui-components/features.js +33 -0
  135. package/framework/js/components/ui-components/flip-card.js +230 -0
  136. package/framework/js/components/ui-components/form-validator.js +76 -0
  137. package/framework/js/components/ui-components/hero.js +49 -0
  138. package/framework/js/components/ui-components/index.js +12 -0
  139. package/framework/js/components/ui-components/interactive-image.js +235 -0
  140. package/framework/js/components/ui-components/interactive-timeline.js +285 -0
  141. package/framework/js/components/ui-components/intro-cards.js +35 -0
  142. package/framework/js/components/ui-components/lightbox.js +652 -0
  143. package/framework/js/components/ui-components/modal.js +386 -0
  144. package/framework/js/components/ui-components/notifications.js +145 -0
  145. package/framework/js/components/ui-components/progress.js +88 -0
  146. package/framework/js/components/ui-components/quote.js +41 -0
  147. package/framework/js/components/ui-components/stats.js +33 -0
  148. package/framework/js/components/ui-components/steps.js +41 -0
  149. package/framework/js/components/ui-components/tabs.js +255 -0
  150. package/framework/js/components/ui-components/timeline.js +42 -0
  151. package/framework/js/components/ui-components/toggle-group.js +73 -0
  152. package/framework/js/components/ui-components/tooltip.js +458 -0
  153. package/framework/js/components/ui-components/value-display.js +133 -0
  154. package/framework/js/components/ui-components/video-player.js +686 -0
  155. package/framework/js/core/component-catalog.js +121 -0
  156. package/framework/js/core/event-bus.js +178 -0
  157. package/framework/js/core/interaction-catalog.js +149 -0
  158. package/framework/js/dev/runtime-linter.js +1725 -0
  159. package/framework/js/drivers/cmi5-driver.js +768 -0
  160. package/framework/js/drivers/driver-factory.js +77 -0
  161. package/framework/js/drivers/driver-interface.js +110 -0
  162. package/framework/js/drivers/http-driver-base.js +241 -0
  163. package/framework/js/drivers/lti-driver.js +508 -0
  164. package/framework/js/drivers/proxy-driver.js +444 -0
  165. package/framework/js/drivers/scorm-12-driver.js +560 -0
  166. package/framework/js/drivers/scorm-2004-driver.js +775 -0
  167. package/framework/js/drivers/scorm-driver-base.js +112 -0
  168. package/framework/js/engagement/engagement-manager.js +404 -0
  169. package/framework/js/engagement/engagement-progress.js +191 -0
  170. package/framework/js/engagement/engagement-trackers.js +215 -0
  171. package/framework/js/engagement/requirement-strategies.js +268 -0
  172. package/framework/js/main.js +727 -0
  173. package/framework/js/managers/accessibility-manager.js +499 -0
  174. package/framework/js/managers/assessment-manager.js +230 -0
  175. package/framework/js/managers/audio-manager.js +944 -0
  176. package/framework/js/managers/comment-manager.js +88 -0
  177. package/framework/js/managers/flag-manager.js +86 -0
  178. package/framework/js/managers/interaction-manager.js +254 -0
  179. package/framework/js/managers/interaction-registry.js +96 -0
  180. package/framework/js/managers/objective-manager.js +423 -0
  181. package/framework/js/managers/score-manager.js +441 -0
  182. package/framework/js/managers/video-manager.js +536 -0
  183. package/framework/js/navigation/Breadcrumbs.js +234 -0
  184. package/framework/js/navigation/NavigationActions.js +1132 -0
  185. package/framework/js/navigation/NavigationState.js +276 -0
  186. package/framework/js/navigation/NavigationUI.js +574 -0
  187. package/framework/js/navigation/document-gallery.js +357 -0
  188. package/framework/js/navigation/navigation-helpers.js +175 -0
  189. package/framework/js/navigation/navigation-validators.js +174 -0
  190. package/framework/js/state/index.js +8 -0
  191. package/framework/js/state/lms-connection.js +482 -0
  192. package/framework/js/state/lms-error-utils.js +58 -0
  193. package/framework/js/state/state-commits.js +200 -0
  194. package/framework/js/state/state-domains.js +86 -0
  195. package/framework/js/state/state-manager.js +502 -0
  196. package/framework/js/state/state-validation.js +311 -0
  197. package/framework/js/state/transaction-log.js +41 -0
  198. package/framework/js/state/xapi-statement-service.js +325 -0
  199. package/framework/js/utilities/access-control.js +99 -0
  200. package/framework/js/utilities/breakpoint-manager.js +315 -0
  201. package/framework/js/utilities/canvas-slide.js +35 -0
  202. package/framework/js/utilities/conditional-display.js +388 -0
  203. package/framework/js/utilities/course-channel.js +214 -0
  204. package/framework/js/utilities/course-helpers.js +420 -0
  205. package/framework/js/utilities/data-reporter.js +273 -0
  206. package/framework/js/utilities/error-reporter.js +313 -0
  207. package/framework/js/utilities/hotspot-helper.js +341 -0
  208. package/framework/js/utilities/icons.js +348 -0
  209. package/framework/js/utilities/logger.js +92 -0
  210. package/framework/js/utilities/markdown-renderer.js +45 -0
  211. package/framework/js/utilities/scroll-tracker.js +68 -0
  212. package/framework/js/utilities/ui-initializer.js +146 -0
  213. package/framework/js/utilities/utilities.js +293 -0
  214. package/framework/js/utilities/view-manager.js +227 -0
  215. package/framework/js/validation/html-validators.js +422 -0
  216. package/framework/js/validation/scorm-validators.js +438 -0
  217. package/framework/js/vendor/pipwerks.js +931 -0
  218. package/framework/scripts/generate-narration.js +629 -0
  219. package/framework/scripts/tts-providers/azure-provider.js +178 -0
  220. package/framework/scripts/tts-providers/base-provider.js +81 -0
  221. package/framework/scripts/tts-providers/deepgram-provider.js +135 -0
  222. package/framework/scripts/tts-providers/elevenlabs-provider.js +148 -0
  223. package/framework/scripts/tts-providers/google-provider.js +272 -0
  224. package/framework/scripts/tts-providers/index.js +158 -0
  225. package/framework/scripts/tts-providers/openai-provider.js +143 -0
  226. package/framework/version.json +63 -0
  227. package/lib/authoring-api.js +919 -0
  228. package/lib/build-linter.js +450 -0
  229. package/lib/build-packaging.js +186 -0
  230. package/lib/build.js +88 -0
  231. package/lib/cloud.js +691 -0
  232. package/lib/convert.js +341 -0
  233. package/lib/course-parser.js +936 -0
  234. package/lib/course-writer.js +258 -0
  235. package/lib/create.js +248 -0
  236. package/lib/css-index.js +237 -0
  237. package/lib/dev.js +51 -0
  238. package/lib/export-content.js +1246 -0
  239. package/lib/headless-browser.js +413 -0
  240. package/lib/import.js +377 -0
  241. package/lib/index.js +80 -0
  242. package/lib/info.js +79 -0
  243. package/lib/interaction-formatters.js +568 -0
  244. package/lib/manifest/cmi5-manifest.js +63 -0
  245. package/lib/manifest/lti-tool-config.js +53 -0
  246. package/lib/manifest/manifest-factory.js +99 -0
  247. package/lib/manifest/scorm-12-manifest.js +61 -0
  248. package/lib/manifest/scorm-2004-manifest.js +94 -0
  249. package/lib/manifest/scorm-proxy-manifest.js +104 -0
  250. package/lib/manifest-parser.js +96 -0
  251. package/lib/mcp-prompts.js +753 -0
  252. package/lib/mcp-server.js +316 -0
  253. package/lib/narration.js +53 -0
  254. package/lib/pdf-structure.js +142 -0
  255. package/lib/preview-export.js +231 -0
  256. package/lib/preview-routes-api.js +662 -0
  257. package/lib/preview-routes-editing.js +159 -0
  258. package/lib/preview-routes-lms.js +230 -0
  259. package/lib/preview-server.js +564 -0
  260. package/lib/project-utils.js +269 -0
  261. package/lib/proxy-templates/proxy.html +68 -0
  262. package/lib/proxy-templates/scorm-bridge.js +112 -0
  263. package/lib/scaffold.js +193 -0
  264. package/lib/schema-extractor.js +361 -0
  265. package/lib/slide-source-editor.js +586 -0
  266. package/lib/stub-player/app-viewer.js +195 -0
  267. package/lib/stub-player/app.js +370 -0
  268. package/lib/stub-player/catalog-panel.js +312 -0
  269. package/lib/stub-player/config-panel.js +1303 -0
  270. package/lib/stub-player/content-generator.js +586 -0
  271. package/lib/stub-player/content-viewer.js +173 -0
  272. package/lib/stub-player/debug-panel.js +420 -0
  273. package/lib/stub-player/edit-mode.js +922 -0
  274. package/lib/stub-player/edit-utils.js +400 -0
  275. package/lib/stub-player/header-bar.js +354 -0
  276. package/lib/stub-player/interaction-editor.js +210 -0
  277. package/lib/stub-player/interactions-panel.js +565 -0
  278. package/lib/stub-player/lms-api.js +1094 -0
  279. package/lib/stub-player/login-screen.js +74 -0
  280. package/lib/stub-player/outline-mode.js +689 -0
  281. package/lib/stub-player/styles/_assessments-panel.css +245 -0
  282. package/lib/stub-player/styles/_base.css +89 -0
  283. package/lib/stub-player/styles/_catalog-icons.css +96 -0
  284. package/lib/stub-player/styles/_catalog-panel.css +291 -0
  285. package/lib/stub-player/styles/_config-panel.css +636 -0
  286. package/lib/stub-player/styles/_content-viewer.css +834 -0
  287. package/lib/stub-player/styles/_debug-panel.css +576 -0
  288. package/lib/stub-player/styles/_edit-mode.css +128 -0
  289. package/lib/stub-player/styles/_header-bar.css +343 -0
  290. package/lib/stub-player/styles/_interaction-editor.css +140 -0
  291. package/lib/stub-player/styles/_interactions-panel.css +1038 -0
  292. package/lib/stub-player/styles/_login-screen.css +102 -0
  293. package/lib/stub-player/styles/_outline-mode.css +752 -0
  294. package/lib/stub-player/styles.css +15 -0
  295. package/lib/stub-player.js +160 -0
  296. package/lib/test-data-reporting.js +176 -0
  297. package/lib/test-error-reporting.js +146 -0
  298. package/lib/token.js +86 -0
  299. package/lib/upgrade.js +257 -0
  300. package/lib/validation-rules.js +517 -0
  301. package/lib/vite-plugin-content-discovery.js +296 -0
  302. package/package.json +108 -0
  303. package/schemas/XMLSchema.dtd +402 -0
  304. package/schemas/adlcp_v1p3.xsd +111 -0
  305. package/schemas/adlnav_v1p3.xsd +61 -0
  306. package/schemas/adlseq_v1p3.xsd +93 -0
  307. package/schemas/common/anyElement.xsd +27 -0
  308. package/schemas/common/dataTypes.xsd +138 -0
  309. package/schemas/common/elementNames.xsd +767 -0
  310. package/schemas/common/elementTypes.xsd +786 -0
  311. package/schemas/common/rootElement.xsd +31 -0
  312. package/schemas/common/vocabTypes.xsd +345 -0
  313. package/schemas/common/vocabValues.xsd +257 -0
  314. package/schemas/datatypes.dtd +203 -0
  315. package/schemas/ims_xml.xsd +35 -0
  316. package/schemas/imscp_v1p1.xsd +368 -0
  317. package/schemas/imsss_v1p0.xsd +67 -0
  318. package/schemas/imsss_v1p0auxresource.xsd +19 -0
  319. package/schemas/imsss_v1p0control.xsd +20 -0
  320. package/schemas/imsss_v1p0delivery.xsd +17 -0
  321. package/schemas/imsss_v1p0limit.xsd +47 -0
  322. package/schemas/imsss_v1p0objective.xsd +67 -0
  323. package/schemas/imsss_v1p0random.xsd +16 -0
  324. package/schemas/imsss_v1p0rollup.xsd +46 -0
  325. package/schemas/imsss_v1p0seqrule.xsd +108 -0
  326. package/schemas/imsss_v1p0util.xsd +94 -0
  327. package/schemas/license.txt +17 -0
  328. package/schemas/lom.xsd +102 -0
  329. package/schemas/lomCustom.xsd +62 -0
  330. package/schemas/lomLoose.xsd +62 -0
  331. package/schemas/lomStrict.xsd +62 -0
  332. package/schemas/xml.xsd +81 -0
  333. package/template/.env.example +92 -0
  334. package/template/course/assets/audio/example-intro.mp3 +0 -0
  335. package/template/course/assets/audio/example-ui-demo--compact-player.mp3 +0 -0
  336. package/template/course/assets/audio/example-ui-demo--demo-modal.mp3 +0 -0
  337. package/template/course/assets/audio/example-ui-demo--full-player.mp3 +0 -0
  338. package/template/course/assets/docs/example_md_1.md +39 -0
  339. package/template/course/assets/docs/example_md_2.md +41 -0
  340. package/template/course/assets/docs/example_pdf_1_thumbnail.png +0 -0
  341. package/template/course/assets/docs/example_pdf_2.pdf +0 -0
  342. package/template/course/assets/images/course-architecture.svg +36 -0
  343. package/template/course/assets/images/logo.svg +14 -0
  344. package/template/course/assets/widgets/counter-demo.html +190 -0
  345. package/template/course/assets/widgets/gravity-painter.html +384 -0
  346. package/template/course/course-config.js +539 -0
  347. package/template/course/icons.js +19 -0
  348. package/template/course/interactions/PLUGIN_GUIDE.md +97 -0
  349. package/template/course/slides/example-course-structure.js +138 -0
  350. package/template/course/slides/example-final-exam.js +144 -0
  351. package/template/course/slides/example-finishing.js +127 -0
  352. package/template/course/slides/example-interactions-showcase.js +615 -0
  353. package/template/course/slides/example-preview-tour.js +129 -0
  354. package/template/course/slides/example-remedial.js +143 -0
  355. package/template/course/slides/example-summary.js +103 -0
  356. package/template/course/slides/example-ui-showcase.js +1805 -0
  357. package/template/course/slides/example-welcome.js +123 -0
  358. package/template/course/slides/example-workflow.js +140 -0
  359. package/template/course/theme.css +165 -0
  360. package/template/eslint.config.js +47 -0
  361. package/template/package.json +28 -0
  362. 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 &nbsp; → 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}>&lt;/&gt;</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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
+ }