diff --git a/admin/views/editor-bootstrap.php b/admin/views/editor-bootstrap.php index 81b62a2..d9d2050 100644 --- a/admin/views/editor-bootstrap.php +++ b/admin/views/editor-bootstrap.php @@ -109,11 +109,8 @@ ); // Inject WordPress configuration BEFORE the closing tag. -// phpcs:disable WordPress.WP.EnqueuedResources.NonEnqueuedScript -- Standalone HTML page output, not a WordPress template. $exelearning_wp_config_script = sprintf( ' - - - ', $exelearning_attachment_id, wp_json_encode( $exelearning_elp_url ), @@ -478,15 +473,11 @@ function normalizeEditorAssetUrl(url) { $exelearning_user_id, wp_json_encode( $exelearning_editor_base_url ), wp_json_encode( $exelearning_i18n ), - wp_json_encode( $exelearning_theme_registry_override ), - esc_url( $exelearning_plugin_assets_url ) + wp_json_encode( $exelearning_theme_registry_override ) ); -// phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript // WordPress-specific styles. $exelearning_page_styles = ' - - '; -// Insert config script and styles before . -$exelearning_template = str_replace( '', $exelearning_wp_config_script . $exelearning_page_styles . '', $exelearning_template ); +// Print only our handles into the standalone document, without a theme header. +wp_enqueue_script( 'exelearning-editor-bridge', $exelearning_plugin_assets_url . '/js/wp-exe-bridge.js', array(), EXELEARNING_VERSION, false ); +wp_add_inline_script( 'exelearning-editor-bridge', $exelearning_wp_config_script, 'before' ); +wp_register_style( 'exelearning-editor-page', false, array(), EXELEARNING_VERSION ); +wp_enqueue_style( 'exelearning-editor-page' ); +wp_add_inline_style( 'exelearning-editor-page', $exelearning_page_styles ); +ob_start(); +wp_print_scripts( array( 'exelearning-editor-bridge' ) ); +wp_print_styles( array( 'exelearning-editor-page' ) ); +$exelearning_integration_assets = ob_get_clean(); +$exelearning_template = str_replace( '', $exelearning_integration_assets . '', $exelearning_template ); // Add tag to set the base URL for all relative paths. // This ensures paths like "files/perm/..." resolve to the static editor directory. diff --git a/assets/js/exelearning-embed.js b/assets/js/exelearning-embed.js new file mode 100644 index 0000000..9aa6116 --- /dev/null +++ b/assets/js/exelearning-embed.js @@ -0,0 +1,85 @@ +/** + * Toolbar and poster behavior for embedded eXeLearning packages. + * + * One enqueued script serves shortcode and block controls, including embeds + * inserted after load. Each click stays within its own embed container. + */ +( function () { + 'use strict'; + + /** + * Promote a deferred frame: load it, reveal it and drop the poster. + * + * In poster mode the iframe ships with its URL in `data-src` and hidden, so the + * package is downloaded only when the visitor asks for it. Called both by the + * poster itself and by the fullscreen button, which must not expand a hidden + * frame that has no document yet. + * + * @param {Element} container The embed. + * @return {void} + */ + function activate( container ) { + var iframe = container.querySelector( '.exelearning-iframe' ); + if ( ! iframe ) { + return; + } + + var deferred = iframe.getAttribute( 'data-src' ); + if ( deferred && ! iframe.getAttribute( 'src' ) ) { + iframe.setAttribute( 'src', deferred ); + } + + iframe.style.display = ''; + + var poster = container.querySelector( '.exelearning-poster' ); + if ( poster ) { + poster.style.display = 'none'; + } + } + + /** + * Take the embed frame fullscreen, whatever the browser calls it. + * + * @param {Element} container The embed. + * @return {void} + */ + function fullscreen( container ) { + var iframe = container.querySelector( '.exelearning-iframe' ); + if ( ! iframe ) { + return; + } + + if ( iframe.requestFullscreen ) { + iframe.requestFullscreen(); + } else if ( iframe.webkitRequestFullscreen ) { + iframe.webkitRequestFullscreen(); + } else if ( iframe.msRequestFullscreen ) { + iframe.msRequestFullscreen(); + } + } + + document.addEventListener( 'click', function ( event ) { + var target = event.target; + if ( ! target || ! target.closest ) { + return; + } + + var control = target.closest( '.exelearning-poster, .exelearning-fullscreen-btn' ); + if ( ! control ) { + return; + } + + var container = control.closest( '.exelearning-preview, .exelearning-block-frontend' ); + if ( ! container ) { + return; + } + + // The fullscreen button in poster mode loads and reveals the frame first: + // expanding a hidden, srcless frame would fill the screen with nothing. + activate( container ); + + if ( control.classList.contains( 'exelearning-fullscreen-btn' ) ) { + fullscreen( container ); + } + } ); +}() ); diff --git a/docs/SHORTCODES.md b/docs/SHORTCODES.md index 210b506..e26b2a7 100644 --- a/docs/SHORTCODES.md +++ b/docs/SHORTCODES.md @@ -90,8 +90,9 @@ content keeps working. `height` applies to the preview iframe (and screenshot poster). Pixel values remain fixed, while percentage values define a responsive height relative to the rendered embed width. For example, `height="75%"` produces a 4:3 preview and -`height="100%"` produces a square preview. The height is recalculated whenever -the embed width changes. +`height="100%"` produces a square preview. It is rendered as a CSS +`aspect-ratio`, so the browser keeps the proportion on every resize with no +script involved. Anything outside the two supported forms (`0`, negative numbers, `calc()`, `var()`, viewport units such as `100vh`, or attempted injections) is rejected and diff --git a/exelearning.php b/exelearning.php index df721cf..efb2ed6 100644 --- a/exelearning.php +++ b/exelearning.php @@ -51,7 +51,7 @@ require_once EXELEARNING_PLUGIN_DIR . 'includes/class-download-formats.php'; require_once EXELEARNING_PLUGIN_DIR . 'includes/class-download-button-renderer.php'; require_once EXELEARNING_PLUGIN_DIR . 'includes/class-elp-upload-block.php'; -require_once EXELEARNING_PLUGIN_DIR . 'includes/class-viewer-enhancements.php'; +require_once EXELEARNING_PLUGIN_DIR . 'includes/class-embed-assets.php'; // Styles management (uploaded/builtin registry). require_once EXELEARNING_PLUGIN_DIR . 'includes/class-style-package.php'; @@ -100,9 +100,6 @@ * Starts the plugin. */ function exelearning_run() { - $viewer_enhancements = new ExeLearning_Viewer_Enhancements(); - $viewer_enhancements->register_hooks(); - $plugin = new ExeLearning(); $plugin->run(); } diff --git a/includes/class-elp-upload-block.php b/includes/class-elp-upload-block.php index f87e19d..b9b917c 100644 --- a/includes/class-elp-upload-block.php +++ b/includes/class-elp-upload-block.php @@ -356,6 +356,10 @@ private function render_block_preview( $data, $download_html ) { // prints; in a REST render the handle was never registered and this is a // harmless no-op. wp_enqueue_script( 'exelearning-embed-loader' ); + + // Drives the fullscreen button, shared with the shortcode. Enqueued here, + // at the point the markup it binds is rendered, for the same reason. + ExeLearning_Embed_Assets::enqueue(); $html .= '
'; $html .= sprintf( '', $iframe_src_attr, - esc_attr( $height ), + esc_attr( $this->height_style( $height ) ), $is_poster ? ' display: none;' : '', esc_attr( $title ) ); return sprintf( - '
+ '
%2$s
@@ -386,104 +386,36 @@ class="exelearning-iframe"
%5$s %6$s -
%7$s', +
', esc_attr( $unique_id ), esc_html( $title ), '' !== $download_html ? $download_html : $fallback_download, $fullscreen_html, $poster_html, $iframe_html, - $this->render_preview_script( $unique_id, $is_poster, $fullscreen ), esc_attr( $width ) ); } /** - * Build the inline behavior script for a preview iframe. - * - * Wires only the behaviors present in this instance: the optional fullscreen - * button and the optional poster click-to-load. When both are enabled the - * fullscreen button first activates the deferred poster (loading and - * revealing the iframe) so it never tries to expand a hidden, srcless frame. - * When neither is enabled no script is emitted. Each block is scoped to the - * instance container so multiple embeds on one page stay independent. + * CSS sizing declarations for an embed of the requested height. * - * Teacher-mode visibility is handled by eXeLearning core through the - * ?exe-teacher=1 query parameter on the iframe src, so no host-side CSS/JS - * injection is emitted here. + * A pixel height is written as-is. A percentage means "this fraction of the + * rendered width" -- the reading every eXeLearning embed has always had -- and + * CSS `height: 75%` does not say that: it resolves against the parent's height, + * which no theme sets, so the frame collapses. `aspect-ratio` states the same + * intent in CSS alone, which is why the script and resize observer this used to + * need are gone. * - * @param string $unique_id Container element ID. - * @param bool $is_poster Whether the iframe loads lazily from a poster. - * @param bool $fullscreen Whether the fullscreen button is present. - * @return string Inline ', - esc_attr( $unique_id ), - $body - ); + return sprintf( 'height: %s;', $height ); } /** @@ -518,6 +450,11 @@ private function render_toolbar_download_fallback( $file_url ) { private function enqueue_frontend_assets() { wp_enqueue_style( 'dashicons' ); + // Drives the fullscreen button and the click-to-load poster for every + // embed on the page, in place of the inline script this used to print + // once per instance. + ExeLearning_Embed_Assets::enqueue(); + wp_enqueue_style( 'exelearning-frontend', plugins_url( '../assets/css/exelearning.css', __FILE__ ), diff --git a/tests/e2e/shortcode-viewer.spec.js b/tests/e2e/shortcode-viewer.spec.js index a133c46..779cc48 100644 --- a/tests/e2e/shortcode-viewer.spec.js +++ b/tests/e2e/shortcode-viewer.spec.js @@ -190,12 +190,13 @@ test.describe('Shortcode viewer (public frontend)', () => { await expect(page.locator('.exelearning-fullscreen-btn')).toBeVisible(); }); - test('height="75%" is applied to the iframe', async ({ page }) => { + test('height="75%" makes the iframe 75% as tall as it is wide', async ({ page }) => { await gotoScenario(page, 'height'); - // A percentage height can compute to 0 without a sized parent, so assert - // on the inline style rather than the element's rendered box. - const style = await page.locator('iframe.exelearning-iframe').getAttribute('style'); - expect(style).toContain('height: 75%'); + // The percentage is rendered as an aspect-ratio, which resolves against the + // embed's own width, so the rendered box is what this can be asserted on -- + // a bare CSS `height: 75%` would have computed to 0 against an unsized parent. + const box = await page.locator('iframe.exelearning-iframe').boundingBox(); + expect(Math.abs(box.height - box.width * 0.75)).toBeLessThanOrEqual(2); }); test('width="75%" is applied to the embed box', async ({ page }) => { diff --git a/tests/js/exelearning_embed.test.js b/tests/js/exelearning_embed.test.js new file mode 100644 index 0000000..74f01dc --- /dev/null +++ b/tests/js/exelearning_embed.test.js @@ -0,0 +1,206 @@ +// Unit tests for assets/js/exelearning-embed.js. +// +// The script replaces the per-instance inline