diff --git a/.github/workflows/build_whl.yml b/.github/workflows/build_whl.yml index a1a1733eb59..b0b1d0244fe 100644 --- a/.github/workflows/build_whl.yml +++ b/.github/workflows/build_whl.yml @@ -8,6 +8,9 @@ on: tar-file-name: description: "TAR file name" value: ${{ jobs.build_whl.outputs.tar-file-name }} + sandbox-static-artifact: + description: "Artifact name for the collected sandbox static files" + value: sandbox-static permissions: contents: read jobs: @@ -64,6 +67,15 @@ jobs: key: cext-${{ hashFiles('requirements/cext.txt', 'requirements/cext_noarch.txt') }} - name: Build Kolibri run: make dist + # Collected here rather than from the installed whl at release time: the built + # static tree is already on disk, and running it on every build means a breakage + # surfaces in PR CI rather than mid-release. + - name: Collect sandbox static files + run: pnpm run collect-sandbox-static dist/static --clear + - uses: actions/upload-artifact@v7 + with: + name: sandbox-static + path: dist/static - name: Get WHL filename id: get-whl-filename run: echo "whl-file-name=$(basename dist/*.whl)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/dependency_updates.yml b/.github/workflows/dependency_updates.yml index 800b31ad1a8..a45066e63cb 100644 --- a/.github/workflows/dependency_updates.yml +++ b/.github/workflows/dependency_updates.yml @@ -35,7 +35,7 @@ jobs: # Get the latest commit from the API response latest_commit = "${{ fromJson(steps.get-commit.outputs.data).commit.sha }}".strip() # Check stored commit - commit_file = Path('packages/kolibri-sandbox/.h5p-commit-sha') + commit_file = Path('kolibri/plugins/h5p_viewer/h5p_build/.h5p-commit-sha') stored_commit = "" if commit_file.exists(): stored_commit = commit_file.read_text().strip() @@ -64,16 +64,16 @@ jobs: if: steps.check-commit.outputs.changed == 'true' run: | # Update the commit file - echo "${{ steps.check-commit.outputs.latest_commit }}" > packages/kolibri-sandbox/.h5p-commit-sha + echo "${{ steps.check-commit.outputs.latest_commit }}" > kolibri/plugins/h5p_viewer/h5p_build/.h5p-commit-sha # Run your script here - pnpm --filter kolibri-sandbox run build-h5p + pnpm --filter kolibri-h5p-viewer-plugin run build-h5p - name: Check for actual build changes if: steps.check-commit.outputs.changed == 'true' id: check-build-changes run: | # Stage only the build output files, excluding the commit SHA file - git add kolibri/core/content/static/h5p packages/kolibri-sandbox/h5p_build.json + git add kolibri/plugins/h5p_viewer/static/h5p kolibri/plugins/h5p_viewer/h5p_build/h5p_build.json if git diff --cached --quiet; then echo "has_build_changes=false" >> "$GITHUB_OUTPUT" else diff --git a/.github/workflows/release_kolibri.yml b/.github/workflows/release_kolibri.yml index 620c2c031d5..097af636242 100644 --- a/.github/workflows/release_kolibri.yml +++ b/.github/workflows/release_kolibri.yml @@ -193,14 +193,11 @@ jobs: destination: '${{ secrets.BCK_PRERELEASE_BUILD_ARTIFACT_GCS_BUCKET }}' parent: false process_gcloudignore: false - - name: Unzip content static files from whl file - run: | - unzip dist/${{ needs.whl.outputs.whl-file-name }} 'kolibri/core/content/static/*' -d static - mv static/kolibri/core/content/static/** static - rm -rf static/kolibri - rm static/**/*.file_size - # Ungzip all .gz files in the static folder - for f in static/**/*.gz; do gunzip -f "$f"; done + - name: Download sandbox static artifact + uses: actions/download-artifact@v8 + with: + name: ${{ needs.whl.outputs.sandbox-static-artifact }} + path: static - name: Upload content static files to BCK bucket uses: 'google-github-actions/upload-cloud-storage@v3' with: diff --git a/.gitignore b/.gitignore index 4b478e6d023..3e97743cd4e 100644 --- a/.gitignore +++ b/.gitignore @@ -107,9 +107,11 @@ js-dist storage/* kolibri/content/content_db/*.sqlite3 kolibri/core/content/contentschema/migrations/* -# Check in h5p & bloom specific files -!kolibri/core/content/static/h5p/ -!kolibri/core/content/static/bloom/ +# Check in h5p & bloom specific files in their respective plugins +!kolibri/plugins/h5p_viewer/static/h5p/ +!kolibri/plugins/bloompub_viewer/static/bloom/ +# H5P PHP library unpacked by build-h5p; only its build output is checked in +kolibri/plugins/h5p_viewer/h5p_build/vendor/ # virtual environment venv/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 63f0ba23b12..e91f7172619 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,4 +1,6 @@ -exclude: (\.git/|\.tox/|\.venv/|build/|static/(?!assets/fonts)|dist/|node_modules/|kolibripip\.pex) +# `build/` matches a whole path segment: unanchored it also swallowed the hand-written +# sources in the kolibri-build package and the h5p_viewer h5p_build scripts. +exclude: (\.git/|\.tox/|\.venv/|(^|/)build/|static/(?!assets/fonts)|dist/|node_modules/|kolibripip\.pex) repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 diff --git a/MANIFEST.in b/MANIFEST.in index 9a0bb80dfef..3e965ba405b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -37,6 +37,7 @@ recursive-exclude kolibri/* *pyo prune kolibri/core/frontend prune kolibri/core/node_modules prune kolibri/plugins/*/frontend +prune kolibri/plugins/*/h5p_build prune kolibri/plugins/*/node_modules exclude kolibri/*/buildConfig.js exclude kolibri/plugins/*/buildConfig.js diff --git a/docs/frontend_architecture/components.rst b/docs/frontend_architecture/components.rst index aeb85bdfb43..17064870cba 100644 --- a/docs/frontend_architecture/components.rst +++ b/docs/frontend_architecture/components.rst @@ -17,7 +17,7 @@ Before creating new components, check for existing ones in this order: 1. **Kolibri Design System** (``kolibri-design-system``) — Always prefer KDS components first. Browse the catalog at https://design-system.learningequality.org/ 2. **Kolibri package** (``packages/kolibri/components/``) — Core application components such as ``AuthMessage``, ``CoreTable``, ``BottomAppBar``, and ``DownloadButton`` -3. **Kolibri-Common package** (``packages/kolibri-common/components/``) — Shared components used across plugins, such as ``AccordionContainer``, ``BaseToolbar``, and ``MetadataChips`` +3. **Kolibri-Common package** (``packages/kolibri-common/components/``) — Shared components used across plugins, such as ``AccordionContainer``, ``EmbeddedReadCard``, and ``MetadataChips`` Only create a new component if none of the above provide what you need. diff --git a/docs/frontend_architecture/single_page_apps.rst b/docs/frontend_architecture/single_page_apps.rst index b38ece7b541..02b25ec92ff 100644 --- a/docs/frontend_architecture/single_page_apps.rst +++ b/docs/frontend_architecture/single_page_apps.rst @@ -86,7 +86,9 @@ This will create a navigation component which will be registered to appear in th Content viewers --------------- -A special kind of Kolibri Module is dedicated to rendering particular content types. All content renderers should extend the ``ContentViewer`` class found in the `kolibri-viewer` package as the default export. In addition, rather than subclassing the ``WebpackBundleHook`` class, content renderers should be defined in the Python code using the ``ContentRendererHook`` class defined in ``kolibri.content.hooks``. In addition to the standard options for the ``WebpackBundleHook``, the ``ContentRendererHook`` also requires a ``presets`` tuple listing the format presets that it will render. +A special kind of Kolibri Module is dedicated to viewing particular content types. All content viewers should extend the ``ContentViewer`` class found in the `kolibri-viewer` package as the default export. In addition, rather than subclassing the ``WebpackBundleHook`` class, content viewers should be defined in the Python code using the ``ContentViewerHook`` class defined in ``kolibri.core.content.hooks``. In addition to the standard options for the ``WebpackBundleHook``, the ``ContentViewerHook`` also requires a ``presets`` tuple listing the format presets that it will handle. + +For content that requires sandboxed execution (HTML5 apps, H5P, Bloom, etc.), use ``SandboxedContentViewerHook`` instead. This hook requires an additional ``sandbox_handler_id`` property and a corresponding sandbox handler bundle in ``buildConfig.js`` with ``sandbox_handler: true``. .. automodule:: kolibri.core.content.hooks :members: diff --git a/eslint.config.mjs b/eslint.config.mjs index 9f91868888a..b62c3c74208 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,6 +21,7 @@ export default [ 'packages/kolibri-jest-config/**', 'packages/kolibri-logging/**', 'packages/build_kolibri_package.js', + 'kolibri/**/h5p_build/**', ], rules: CJS_RULES, }, diff --git a/kolibri/core/content/hooks.py b/kolibri/core/content/hooks.py index 1f22a294e54..53530f76808 100644 --- a/kolibri/core/content/hooks.py +++ b/kolibri/core/content/hooks.py @@ -2,31 +2,39 @@ Kolibri Content hooks --------------------- -Hooks for managing the display and rendering of content. +Hooks for managing the display and viewing of content. """ import json +import logging from abc import abstractmethod +from django.conf import settings from django.core.serializers.json import DjangoJSONEncoder from django.utils.safestring import mark_safe +from importlib_resources import files from le_utils.constants import file_formats from le_utils.constants import format_presets +from kolibri.core.content.utils.paths import zip_content_static_root +from kolibri.core.utils.urls import join_url from kolibri.core.webpack.hooks import WebpackBundleHook +from kolibri.core.webpack.hooks import WebpackError from kolibri.core.webpack.hooks import WebpackInclusionMixin from kolibri.plugins.hooks import define_hook from kolibri.plugins.hooks import KolibriHook +logger = logging.getLogger(__name__) + @define_hook -class ContentRendererHook(WebpackBundleHook, WebpackInclusionMixin): +class ContentViewerHook(WebpackBundleHook, WebpackInclusionMixin): """ An inheritable hook that allows special behaviour for a frontend module that defines - a content renderer. + a content viewer. """ - #: Set tuple of format presets that this content renderer can handle + #: Set tuple of format presets that this content viewer can handle @property @abstractmethod def presets(self): @@ -63,16 +71,28 @@ def html(cls): tags.append(hook.template_html()) return mark_safe("\n".join(tags)) - def template_html(self): + @property + def viewer_data(self): """ - Generates template tags containing data to register a content renderer. + Data registering this content viewer with the frontend. - :returns: HTML of a template tags to insert into a page. + :returns: dict serialized into this viewer's template tag. """ # Note, while most plugins use sorted chunks to filter by text direction - # content renderers do not, as they may need to have styling for a different + # content viewers do not, as they may need to have styling for a different # text direction than the interface due to the text direction of content - urls = [chunk["url"] for chunk in self.bundle] + return { + "urls": [chunk["url"] for chunk in self.bundle], + "presets": self.presets, + "css_selectors": self.all_css_selectors(), + } + + def template_html(self): + """ + Generates template tags containing data to register a content viewer. + + :returns: HTML of a template tags to insert into a page. + """ tags = ( self.frontend_message_tag() + self.plugin_data_tag() @@ -80,11 +100,7 @@ def template_html(self): ''.format( bundle=self.unique_id, data=json.dumps( - { - "urls": urls, - "presets": self.presets, - "css_selectors": self.all_css_selectors(), - }, + self.viewer_data, separators=(",", ":"), ensure_ascii=False, cls=DjangoJSONEncoder, @@ -95,6 +111,120 @@ def template_html(self): return mark_safe("\n".join(tags)) +# Backwards compatibility alias +ContentRendererHook = ContentViewerHook + + +@define_hook +class SandboxedContentViewerHook(ContentViewerHook): + """ + A content viewer that uses the Kolibri sandbox with a dynamically loaded handler. + + Subclasses must define: + - bundle_id: The main viewer bundle ID (inherited from WebpackBundleHook) + - presets: Tuple of format presets this viewer handles (inherited from ContentViewerHook) + - sandbox_handler_id: The bundle ID of the sandbox handler + + The sandbox handler is built separately with no Kolibri externals and loaded + dynamically into the sandbox iframe at runtime. + """ + + @property + @abstractmethod + def sandbox_handler_id(self): + """ + Bundle ID of the sandbox handler. + This should match a bundle defined in buildConfig.js with sandbox_handler: true + """ + pass + + @property + def sandbox_static_path(self): + """ + Returns the filesystem path to the plugin's static directory. + """ + return str(files(self._module_path).joinpath("static")) + + @classmethod + def get_sandbox_static_paths(cls): + """ + Returns a list of filesystem paths to static directories + that should be mounted on the sandbox server. + + Includes: + - Core content static directory (kolibri/core/content/static) + - Plugin static directories for each registered sandbox handler + """ + core_static_path = str(files("kolibri.core.content").joinpath("static")) + return [core_static_path] + [ + hook.sandbox_static_path for hook in cls.registered_hooks + ] + + @property + def sandbox_handler_unique_id(self): + """Full unique ID for the sandbox handler bundle.""" + return "{}.{}".format(self._module_path, self.sandbox_handler_id) + + def _get_sandbox_handler_stats(self): + """Load stats file for the sandbox handler bundle.""" + developer_mode = getattr(settings, "DEVELOPER_MODE", False) + if hasattr(self, "_cached_sandbox_handler_stats") and not developer_mode: + return self._cached_sandbox_handler_stats + + try: + stats = json.loads( + files(self._module_path) + .joinpath("build") + .joinpath("{}_stats.json".format(self.sandbox_handler_unique_id)) + .read_text() + ) + except OSError as e: + raise WebpackError( + "Error accessing sandbox handler stats file '{}': {}".format( + self.sandbox_handler_unique_id, e + ) + ) + + self._cached_sandbox_handler_stats = stats + return stats + + @property + def sandbox_handler_url(self): + """URL to the built sandbox handler JavaScript file.""" + stats = self._get_sandbox_handler_stats() + chunks = stats.get("chunks", {}).get(self.sandbox_handler_unique_id, []) + + for chunk in chunks: + name = chunk.get("name", "") + if name.endswith(".js"): + relpath = "{}/{}".format(self.sandbox_handler_unique_id, name) + if getattr(settings, "DEVELOPER_MODE", False): + url = chunk.get("publicPath") + if url and not url.startswith("auto"): + return url + # The handler - - - diff --git a/kolibri/plugins/bloompub_viewer/kolibri_plugin.py b/kolibri/plugins/bloompub_viewer/kolibri_plugin.py index 8436e8ea814..94f47f241c0 100644 --- a/kolibri/plugins/bloompub_viewer/kolibri_plugin.py +++ b/kolibri/plugins/bloompub_viewer/kolibri_plugin.py @@ -10,6 +10,7 @@ class BloomPubRenderPlugin(KolibriPluginBase): @register_hook -class BloomPubRenderAsset(content_hooks.ContentRendererHook): +class BloomPubRenderAsset(content_hooks.SandboxedContentViewerHook): bundle_id = "main" + sandbox_handler_id = "sandbox_handler" presets = (format_presets.BLOOMPUB,) diff --git a/kolibri/plugins/bloompub_viewer/package.json b/kolibri/plugins/bloompub_viewer/package.json index d9128ca43db..4e273e17aa4 100644 --- a/kolibri/plugins/bloompub_viewer/package.json +++ b/kolibri/plugins/bloompub_viewer/package.json @@ -4,12 +4,15 @@ "private": true, "version": "0.0.1", "dependencies": { + "core-js": "catalog:", "kolibri": "workspace:*", "kolibri-common": "workspace:*", "kolibri-design-system": "catalog:", "kolibri-sandbox": "workspace:*", "kolibri-viewer": "workspace:*", + "kolibri-zip": "workspace:*", "lodash": "catalog:", - "vue": "catalog:" + "vue": "catalog:", + "web-streams-polyfill": "catalog:" } } \ No newline at end of file diff --git a/kolibri/core/content/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js b/kolibri/plugins/bloompub_viewer/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js similarity index 100% rename from kolibri/core/content/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js rename to kolibri/plugins/bloompub_viewer/static/bloom/bloomPlayer-02a77592459cdf0b1bd7.min.js diff --git a/kolibri/core/content/static/bloom/bloomplayer.htm b/kolibri/plugins/bloompub_viewer/static/bloom/bloomplayer.htm similarity index 67% rename from kolibri/core/content/static/bloom/bloomplayer.htm rename to kolibri/plugins/bloompub_viewer/static/bloom/bloomplayer.htm index 6ba5d8a90e8..78ecb9bb4c6 100644 --- a/kolibri/core/content/static/bloom/bloomplayer.htm +++ b/kolibri/plugins/bloompub_viewer/static/bloom/bloomplayer.htm @@ -5,6 +5,12 @@ + +
diff --git a/kolibri/core/content/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 b/kolibri/plugins/bloompub_viewer/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 similarity index 100% rename from kolibri/core/content/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 rename to kolibri/plugins/bloompub_viewer/static/bloom/right_answer-913c37e88e2939122d763361833efd24.mp3 diff --git a/kolibri/core/content/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 b/kolibri/plugins/bloompub_viewer/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 similarity index 100% rename from kolibri/core/content/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 rename to kolibri/plugins/bloompub_viewer/static/bloom/wrong_answer-f96cfc1e0cc2cea2dd523d521d4c8738.mp3 diff --git a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue index c3742753054..a9a0c322ae9 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/EpubRendererIndex.vue @@ -4,7 +4,7 @@ :active="mobileEmbedded" @read="$refs.epubViewer.toggleFullscreen()" > - - + @@ -165,7 +165,7 @@ import clamp from 'lodash/clamp'; import Lockr from 'lockr'; import FocusLock from 'vue-focus-lock'; - import CoreFullscreen from 'kolibri-common/components/CoreFullscreen'; + import Fullscreen from 'kolibri/components/Fullscreen'; import EmbeddedReadCard from 'kolibri-common/components/EmbeddedReadCard'; import useKResponsiveWindow from 'kolibri-design-system/lib/composables/useKResponsiveWindow'; import useContentViewer from 'kolibri/composables/useContentViewer'; @@ -200,7 +200,7 @@ export default { name: 'EpubRendererIndex', components: { - CoreFullscreen, + Fullscreen, EmbeddedReadCard, TopBar, TableOfContentsSideBar, diff --git a/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue b/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue index 96eda9dbc84..bfe7ec92f7b 100644 --- a/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue +++ b/kolibri/plugins/epub_viewer/frontend/views/TopBar.vue @@ -36,7 +36,7 @@ - - - diff --git a/kolibri/plugins/html5_viewer/frontend/views/__tests__/Html5AppRendererIndex.spec.js b/kolibri/plugins/html5_viewer/frontend/views/__tests__/Html5AppRendererIndex.spec.js deleted file mode 100644 index 7fa8d38327b..00000000000 --- a/kolibri/plugins/html5_viewer/frontend/views/__tests__/Html5AppRendererIndex.spec.js +++ /dev/null @@ -1,28 +0,0 @@ -import Html5AppRendererIndex from '../Html5AppRendererIndex'; - -const { methods } = Html5AppRendererIndex; - -describe('recordProgress', () => { - let context = {}; - - beforeEach(() => { - context = { - $emit: jest.fn(), - durationBasedProgress: 0.1, - pollProgress: jest.fn(), - sandbox: { - getProgress() { - return 0.1; - }, - }, - }; - }); - - it('should be able to calculate progress using time-based tracking when progress is null', () => { - context.sandbox = null; - methods.recordProgress.call(context); - - expect(context.$emit.mock.calls[0][0]).toBe('updateProgress'); - expect(context.$emit.mock.calls[0][1]).toBe(0.1); - }); -}); diff --git a/kolibri/plugins/html5_viewer/kolibri_plugin.py b/kolibri/plugins/html5_viewer/kolibri_plugin.py index b3af7aea151..491a2802f36 100644 --- a/kolibri/plugins/html5_viewer/kolibri_plugin.py +++ b/kolibri/plugins/html5_viewer/kolibri_plugin.py @@ -10,10 +10,10 @@ class HTML5AppPlugin(KolibriPluginBase): @register_hook -class HTML5AppAsset(content_hooks.ContentRendererHook): +class HTML5AppAsset(content_hooks.SandboxedContentViewerHook): bundle_id = "main" + sandbox_handler_id = "sandbox_handler" presets = ( format_presets.HTML5_ZIP, - format_presets.H5P_ZIP, format_presets.IMSCP_ZIP, ) diff --git a/kolibri/plugins/html5_viewer/package.json b/kolibri/plugins/html5_viewer/package.json index 544b3ed687b..2da4b43e946 100644 --- a/kolibri/plugins/html5_viewer/package.json +++ b/kolibri/plugins/html5_viewer/package.json @@ -4,6 +4,7 @@ "private": true, "version": "0.0.1", "dependencies": { + "core-js": "catalog:", "kolibri": "workspace:*", "kolibri-common": "workspace:*", "kolibri-design-system": "catalog:", diff --git a/kolibri/plugins/learn/frontend/views/AssessmentWrapper/LessonMasteryBar.vue b/kolibri/plugins/learn/frontend/views/AssessmentWrapper/LessonMasteryBar.vue index 47c6afb16a3..db1601aa02d 100644 --- a/kolibri/plugins/learn/frontend/views/AssessmentWrapper/LessonMasteryBar.vue +++ b/kolibri/plugins/learn/frontend/views/AssessmentWrapper/LessonMasteryBar.vue @@ -22,7 +22,7 @@ + + + diff --git a/packages/kolibri/components/SandboxedContentViewer/internal/setup.js b/packages/kolibri/components/SandboxedContentViewer/internal/setup.js new file mode 100644 index 00000000000..9ef2c1726c8 --- /dev/null +++ b/packages/kolibri/components/SandboxedContentViewer/internal/setup.js @@ -0,0 +1,76 @@ +import { ref, computed, onMounted } from 'vue'; +import { createTranslator } from 'kolibri/utils/i18n'; +import useSandbox from '../../../composables/internal/useSandbox'; + +const FRAME_TOPBAR_HEIGHT = '48px'; + +const { contentFrameTitle$ } = createTranslator('SandboxedContentViewer', { + contentFrameTitle: { + message: 'Content viewer', + context: 'Accessible title for the iframe that displays the content', + }, +}); + +export default function sandboxedContentViewerSetup(props, context, options = {}) { + const { + defaultDuration = null, + progressPollingInterval = null, + urlBuilder = null, + eventHandlers = {}, + } = options; + + const viewer = useSandbox(context, { + defaultDuration, + progressPollingInterval, + urlBuilder, + eventHandlers, + }); + + const fullscreenRef = ref(null); + const iframeElement = ref(null); + const isFullscreen = ref(false); + + const containerStyle = computed(() => { + if (isFullscreen.value) { + return { + position: 'absolute', + top: FRAME_TOPBAR_HEIGHT, + bottom: 0, + }; + } + return {}; + }); + + function toggleFullscreen() { + fullscreenRef.value?.toggleFullscreen(); + } + + onMounted(() => { + viewer.iframeRef.value = iframeElement.value; + + if (viewer.isSandboxed.value) { + // Initialize sandbox immediately (like the old code did) + // The sandbox handles the iframe load timing internally via READYCHECK/IFRAMEREADY + viewer.initializeSandbox(); + context.emit('startTracking'); + } else { + viewer.reportLoadingFailure( + `No sandbox handler registered for preset ${viewer.defaultItemPreset.value}`, + ); + } + }); + + return { + fullscreenRef, + iframeElement, + isFullscreen, + // Exposed so a host can bind its own handlers to the client once it exists. + sandbox: viewer.sandbox, + loading: viewer.loading, + sandboxUrl: viewer.sandboxUrl, + contentFrameTitle: contentFrameTitle$(), + containerStyle, + toggleFullscreen, + getProgress: viewer.getProgress, + }; +} diff --git a/packages/kolibri-common/components/ViewerToolbar.vue b/packages/kolibri/components/ViewerToolbar.vue similarity index 97% rename from packages/kolibri-common/components/ViewerToolbar.vue rename to packages/kolibri/components/ViewerToolbar.vue index 203b78a63fe..b6e350256a0 100644 --- a/packages/kolibri-common/components/ViewerToolbar.vue +++ b/packages/kolibri/components/ViewerToolbar.vue @@ -29,7 +29,7 @@