From 3f9492d9a29668a71b969860846ebcfa9b842918 Mon Sep 17 00:00:00 2001 From: erseco Date: Fri, 18 Sep 2026 19:59:31 +0100 Subject: [PATCH 1/3] fix: serve embed behavior as an enqueued script instead of inline markup Every shortcode and block preview printed its own ', - esc_attr( $container_id ) - ); - } } diff --git a/includes/class-embed-assets.php b/includes/class-embed-assets.php new file mode 100644 index 0000000..2d64f18 --- /dev/null +++ b/includes/class-embed-assets.php @@ -0,0 +1,40 @@ + printed once per embed. Both now enqueue this one file instead. + */ +class ExeLearning_Embed_Assets { + + /** + * Enqueue the script that drives the embed controls. + * + * Called from the renderers rather than from `wp_enqueue_scripts`: a page with + * no embed has nothing for the script to bind, and shortcodes render during + * `the_content`, after that hook has already run. Safe to call repeatedly; + * WordPress de-duplicates by handle. + * + * @return void + */ + public static function enqueue() { + wp_enqueue_script( + 'exelearning-embed', + plugins_url( 'assets/js/exelearning-embed.js', EXELEARNING_PLUGIN_FILE ), + array(), + EXELEARNING_VERSION, + true + ); + } +} diff --git a/includes/class-viewer-enhancements.php b/includes/class-viewer-enhancements.php deleted file mode 100644 index 23e22ff..0000000 --- a/includes/class-viewer-enhancements.php +++ /dev/null @@ -1,96 +0,0 @@ - - #%1$s[data-exelearning-responsive-height="1"] .exelearning-iframe, - #%1$s[data-exelearning-responsive-height="1"] .exelearning-poster { - height: var(--exelearning-responsive-height) !important; - } - - ', - esc_attr( $container_id ), - wp_json_encode( $container_id ), - $percentage - ); - - return $html; - } -} diff --git a/public/class-shortcodes.php b/public/class-shortcodes.php index 9ab224e..d32a433 100644 --- a/public/class-shortcodes.php +++ b/public/class-shortcodes.php @@ -346,12 +346,12 @@ private function render_preview( $title, $preview_url, $width, $height, $file_ur $poster_html = ''; if ( $is_poster ) { $poster_html = sprintf( - '', - esc_attr( $height ), + esc_attr( $this->height_style( $height ) ), esc_url( $poster_url ), esc_attr( $title ), esc_html__( 'Load interactive content', 'exelearning' ) @@ -362,7 +362,7 @@ private function render_preview( $title, $preview_url, $width, $height, $file_ur '', $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..84d87df --- /dev/null +++ b/tests/js/exelearning_embed.test.js @@ -0,0 +1,194 @@ +// Unit tests for assets/js/exelearning-embed.js. +// +// The script replaces the per-instance inline - ', $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 index c62ed9e..9aa6116 100644 --- a/assets/js/exelearning-embed.js +++ b/assets/js/exelearning-embed.js @@ -1,36 +1,12 @@ /** * Toolbar and poster behavior for embedded eXeLearning packages. * - * The shortcode and the block used to print one - ', + ', wp_json_encode( $config ), wp_json_encode( $editor_base_url ), - wp_json_encode( $elp_url ), - esc_url( $bridge_url ) + wp_json_encode( $elp_url ) ); - // phpcs:enable WordPress.WP.EnqueuedResources.NonEnqueuedScript + wp_enqueue_script( 'exelearning-export-bridge', $bridge_url, array(), EXELEARNING_VERSION, false ); + wp_add_inline_script( 'exelearning-export-bridge', $script, 'before' ); + // The bridge initializes on DOMContentLoaded, also on WordPress 6.1. + ob_start(); + wp_print_scripts( array( 'exelearning-export-bridge' ) ); + $inject = ob_get_clean(); // Inject config and our bridge before . $template = str_replace( '', $inject . '', $template ); diff --git a/tests/js/exelearning_embed.test.js b/tests/js/exelearning_embed.test.js index 84d87df..74f01dc 100644 --- a/tests/js/exelearning_embed.test.js +++ b/tests/js/exelearning_embed.test.js @@ -82,6 +82,18 @@ afterEach( () => { } ); describe( 'exelearning-embed: the fullscreen button', () => { + it( 'does not reach a neighboring embed when its own frame is missing', () => { + document.body.innerHTML = + embedMarkup( { id: 'exelearning-1', fullscreen: true } ) + + embedMarkup( { id: 'exelearning-2', fullscreen: true } ); + document.querySelector( '#exelearning-1 iframe' ).remove(); + const [ neighbor ] = stubFullscreen(); + + click( document.querySelector( '#exelearning-1 .exelearning-fullscreen-btn' ) ); + + expect( neighbor.length ).toBe( 0 ); + } ); + it( 'fullscreens the frame of the embed it belongs to', () => { document.body.innerHTML = embedMarkup( { id: 'exelearning-1', fullscreen: true } ) + diff --git a/tests/unit/EditorBootstrapPageTest.php b/tests/unit/EditorBootstrapPageTest.php index 878ebad..0b0eb3b 100644 --- a/tests/unit/EditorBootstrapPageTest.php +++ b/tests/unit/EditorBootstrapPageTest.php @@ -100,6 +100,9 @@ class EditorBootstrapPageTest extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); + // Each standalone document is a new request with fresh asset queues. + $GLOBALS['wp_scripts'] = null; + $GLOBALS['wp_styles'] = null; $this->editor = new ExeLearning_Editor(); $this->cleanup_paths = array(); $_GET = array(); @@ -301,12 +304,19 @@ public function test_relative_asset_paths_in_the_bundle_are_rewritten() { public function test_the_page_loads_the_wordpress_bridge() { wp_set_current_user( $this->factory->user->create( array( 'role' => 'administrator' ) ) ); + wp_enqueue_script( 'unrelated-theme-script', 'https://example.org/theme.js' ); + wp_enqueue_style( 'unrelated-theme-style', 'https://example.org/theme.css' ); + $html = $this->editor->build_bootstrap_page( $this->make_elpx() ); $this->assertStringContainsString( esc_url( EXELEARNING_PLUGIN_URL . 'assets' ) . '/js/wp-exe-bridge.js', $html ); + $this->assertTrue( wp_script_is( 'exelearning-editor-bridge', 'done' ) ); + $this->assertTrue( wp_style_is( 'exelearning-editor-page', 'done' ) ); + $this->assertStringNotContainsString( 'https://example.org/theme.', $html ); + $this->assertLessThan( strpos( $html, 'src=' ), strpos( $html, 'window.__WP_EXE_CONFIG__' ) ); } /** diff --git a/tests/unit/ExportBootstrapPayloadTest.php b/tests/unit/ExportBootstrapPayloadTest.php index e7905b7..c4a0649 100644 --- a/tests/unit/ExportBootstrapPayloadTest.php +++ b/tests/unit/ExportBootstrapPayloadTest.php @@ -45,6 +45,9 @@ class ExportBootstrapPayloadTest extends WP_UnitTestCase { */ public function set_up() { parent::set_up(); + // Each standalone document is a new request with fresh asset queues. + $GLOBALS['wp_scripts'] = null; + $GLOBALS['wp_styles'] = null; $this->bootstrap = new ExeLearning_Export_Bootstrap(); $this->editor_base_url = EXELEARNING_PLUGIN_URL . 'dist/static'; $this->export_cleanup_paths = array(); @@ -91,12 +94,16 @@ public function test_the_export_configuration_is_injected_as_json() { * The bridge script is loaded from the plugin, cache-busted by version. */ public function test_the_bridge_script_is_loaded_from_the_plugin() { + wp_enqueue_script( 'unrelated-theme-script', 'https://example.org/theme.js' ); $html = $this->inject( '' ); $this->assertStringContainsString( esc_url( EXELEARNING_PLUGIN_URL . 'assets/js/wp-exe-bridge.js?ver=' . EXELEARNING_VERSION ), $html ); + $this->assertTrue( wp_script_is( 'exelearning-export-bridge', 'done' ) ); + $this->assertStringNotContainsString( 'https://example.org/theme.js', $html ); + $this->assertLessThan( strpos( $html, 'src=' ), strpos( $html, 'window.__WP_EXE_CONFIG__' ) ); } /**