diff --git a/.distignore b/.distignore index 6d56a32..cba0493 100644 --- a/.distignore +++ b/.distignore @@ -74,6 +74,7 @@ sftp-config.json AGENTS.md EXELEARNING_CHANGES.md playwright.config.* +vitest.config.* scripts cloudflare-worker mkdocs.yml diff --git a/.gitattributes b/.gitattributes index 1c98209..27e481e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -48,6 +48,7 @@ sftp-config.json export-ignore sonar-project.properties export-ignore test.php export-ignore tests/ export-ignore +vitest.config.* export-ignore # eXeLearning submodule (built editor is in dist/static/) exelearning/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f924c3..508349b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,10 @@ jobs: - name: Run code linting (PHPCS) run: ./vendor/bin/phpcs --standard=.phpcs.xml.dist -q . + # === JS UNIT TESTS (no WordPress runtime needed; fail fast before wp-env) === + - name: Run JavaScript unit tests + run: npm run test:js + # === START ENVIRONMENT (with Xdebug for coverage) === - name: Start wp-env with coverage run: npx wp-env start --xdebug=coverage diff --git a/assets/css/exelearning.css b/assets/css/exelearning.css index 1285c74..db94fd6 100644 --- a/assets/css/exelearning.css +++ b/assets/css/exelearning.css @@ -291,6 +291,59 @@ max-width: 100%; } +/* Loading state for the embedded package. + The package is fetched, parsed and laid out inside the iframe before anything + paints, so without this the visitor stares at an empty frame. The spinner is + hidden unless the enqueued loader adds `is-loading`, so with JS disabled + nothing is shown that JS would never be able to clear. */ +.exelearning-embed-loader { + position: relative; +} + +.exelearning-embed-loader__spinner { + display: none; + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + gap: 0.6em; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + color: #50575e; + font-size: 0.875rem; +} + +/* Only the visibility depends on the state. */ +.exelearning-embed-loader.is-loading .exelearning-embed-loader__spinner { + display: flex; +} + +.exelearning-embed-loader__spinner::before { + content: ""; + width: 1.25em; + height: 1.25em; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: exelearning-embed-spin 0.8s linear infinite; +} + +@keyframes exelearning-embed-spin { + to { + transform: rotate(360deg); + } +} + +/* A slower spin is still a spin. Someone who asked for reduced motion gets none: the + ring stays as a static shape, and the "Loading content…" text with role="status" + carries the message on its own. */ +@media (prefers-reduced-motion: reduce) { + .exelearning-embed-loader__spinner::before { + animation: none; + } +} + .exelearning-screenshot-img { display: block; width: 100%; diff --git a/assets/js/exelearning-embed-loader.js b/assets/js/exelearning-embed-loader.js new file mode 100644 index 0000000..c6b622f --- /dev/null +++ b/assets/js/exelearning-embed-loader.js @@ -0,0 +1,140 @@ +/** + * Loading state for embedded eXeLearning packages. + * + * The iframe has to download, parse and lay out a whole package before anything + * paints, so without this the visitor stares at an empty bordered frame with no + * clue whether it is loading or broken. + * + * Binds every `.exelearning-embed-loader` on the page in one pass, so a page + * with several embeds costs one cached script instead of one inline copy per + * block. The spinner is revealed from here, never server-side: a visitor + * without JavaScript then never gets an overlay that nothing could clear. + */ +( function () { + 'use strict'; + + /** Give up after this long so a failed embed cannot spin forever. */ + var BACKSTOP_MS = 20000; + + /** + * How far ahead of the viewport to arm the spinner. + * + * It has to be at least the browser's own lazy-loading fetch distance, which is + * far larger than "nearly visible" -- Chrome uses a viewport-distance threshold + * in the low thousands of pixels. A margin smaller than that arms the spinner + * only after the frame has already been fetched. + */ + var FETCH_MARGIN = '1500px'; + + /** + * Whether the frame finished loading before this script got a chance to listen. + * + * The loader is enqueued in the footer and binds no earlier than DOMContentLoaded, + * so an embed near the top of a long page -- a cached one especially -- can fire + * its load event before bind() ever runs. `load` is not re-emitted, so without + * asking the frame directly it would look unloaded forever and the spinner would + * cover finished content until the backstop expired. + * + * The embed is same-origin (the REST content proxy on this host) and keeps + * allow-same-origin in its sandbox, so its document is readable. The about:blank + * test is what makes reading it safe: a lazy frame whose fetch has NOT started + * yet also reports readyState 'complete', on the placeholder document it was + * created with. Treating that as loaded would suppress the spinner in exactly the + * case it exists for. + * + * @param {HTMLIFrameElement} iframe The embed frame. + * @return {boolean} True only when a real document has finished loading. + */ + function hasAlreadyLoaded( iframe ) { + try { + var doc = iframe.contentDocument; + return !! doc && + 'complete' === doc.readyState && + !! doc.location && + 'about:blank' !== doc.location.href; + } catch ( e ) { + // Cross-origin, so unreadable. Fall through to the normal event flow. + return false; + } + } + + function bind( wrap ) { + var iframe = wrap.querySelector( '.exelearning-iframe' ); + if ( ! iframe || wrap.dataset.exeLoaderBound ) { + return; + } + wrap.dataset.exeLoaderBound = '1'; + + // Once the frame has painted -- or failed, or outlived the backstop -- the + // spinner must never appear again. The margin below is a heuristic about + // someone else's fetch scheduling; this is the invariant that does not + // depend on getting that heuristic right. + var settled = false; + + var clear = function () { + settled = true; + wrap.classList.remove( 'is-loading' ); + }; + // `load` fires when the package finished downloading, but the eXeLearning + // theme still builds its navigation right after, so clearing immediately + // can uncover a frame that is still blank. + var clearSoon = function () { + setTimeout( clear, 150 ); + }; + + var start = function () { + if ( settled ) { + return; + } + wrap.classList.add( 'is-loading' ); + setTimeout( clear, BACKSTOP_MS ); + }; + + iframe.addEventListener( 'load', function () { + // Settled now, not in 150 ms: the frame is done, and a scroll landing in + // between must not be able to drop a spinner over the loaded content. + settled = true; + clearSoon(); + } ); + iframe.addEventListener( 'error', clear ); + + // A frame that finished before we got here will never re-emit `load`, so ask + // it directly instead of waiting for an event that already happened. + if ( hasAlreadyLoaded( iframe ) ) { + settled = true; + } + + // The iframe is `loading="lazy"`, so the browser owns the fetch schedule and + // starts it long before the frame is on screen. Arming the spinner at parse + // time would leave it over a frame nothing is fetching yet; arming it only + // once the frame is nearly visible would leave it over a frame that already + // finished. The margin covers the browser's fetch distance, and `settled` + // covers whatever the margin gets wrong. + if ( ! ( 'IntersectionObserver' in window ) ) { + start(); + return; + } + var observer = new IntersectionObserver( + function ( entries ) { + if ( ! entries.some( function ( entry ) { return entry.isIntersecting; } ) ) { + return; + } + observer.disconnect(); + start(); + }, + { rootMargin: FETCH_MARGIN } + ); + observer.observe( iframe ); + } + + function bindAll() { + var wraps = document.querySelectorAll( '.exelearning-embed-loader' ); + Array.prototype.forEach.call( wraps, bind ); + } + + if ( 'loading' === document.readyState ) { + document.addEventListener( 'DOMContentLoaded', bindAll ); + } else { + bindAll(); + } +}() ); diff --git a/includes/class-elp-upload-block.php b/includes/class-elp-upload-block.php index 3ef0813..95f3733 100644 --- a/includes/class-elp-upload-block.php +++ b/includes/class-elp-upload-block.php @@ -26,7 +26,12 @@ public function __construct() { } /** - * Enqueue frontend styles. + * Enqueue frontend styles and register the shared embed-loader behavior. + * + * The loader is registered here but not enqueued: a page with no eXeLearning + * block has nothing for it to bind, and enqueueing from this hook would put the + * request on every frontend page of the site. render_block_preview() enqueues it + * at the point it emits a wrapper for the loader to find. */ public function enqueue_frontend_styles() { wp_enqueue_style( @@ -35,6 +40,15 @@ public function enqueue_frontend_styles() { array(), EXELEARNING_VERSION ); + // Binds every `.exelearning-embed-loader` on the page at once, so the + // spinner costs one cached file instead of an inline copy per block. + wp_register_script( + 'exelearning-embed-loader', + plugins_url( '../assets/js/exelearning-embed-loader.js', __FILE__ ), + array(), + EXELEARNING_VERSION, + true + ); } /** @@ -300,6 +314,15 @@ private function render_block_preview( $data, $download_html ) { $html .= '
' . $download_html . $fullscreen_html . '
'; } + // The package is downloaded, parsed and laid out inside the iframe before + // anything paints, which reads as a blank frame for a noticeable moment. + // Wrap it so the loader script can cover that gap with a spinner, and pull + // the script in here so it only ships on pages that actually have a wrapper. + // It is a footer script enqueued while the content renders, which still + // prints; in a REST render the handle was never registered and this is a + // harmless no-op. + wp_enqueue_script( 'exelearning-embed-loader' ); + $html .= '
'; $html .= sprintf( '' + + '
Loading…
' + + '
'; + + const iframe = document.querySelector( '.exelearning-iframe' ); + Object.defineProperty( iframe, 'contentDocument', { + configurable: true, + get() { + if ( frame.throws ) { + throw new Error( 'cross-origin' ); + } + if ( ! frame.readyState ) { + return null; + } + return { readyState: frame.readyState, location: { href: frame.href } }; + }, + } ); + + return document.querySelector( '.exelearning-embed-loader' ); +} + +/** Import a fresh copy of the loader, which binds on import. */ +async function runLoader() { + await import( /* @vite-ignore */ `${ LOADER }?load=${ ++loadCount }` ); +} + +beforeEach( () => { + installIntersectionObserver(); +} ); + +afterEach( () => { + document.body.innerHTML = ''; + observers = []; +} ); + +describe( 'exelearning-embed-loader: a frame that finished before the loader bound', () => { + it( 'never covers an embed whose document already loaded', async () => { + const wrap = buildEmbed( { readyState: 'complete', href: 'http://example.test/embed.html' } ); + + await runLoader(); + observers[ 0 ].intersect(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( false ); + } ); + + it( 'still covers a lazy embed whose fetch has not started', async () => { + // A lazy frame the browser has not fetched yet reports readyState 'complete' on + // its about:blank placeholder. Reading that as "loaded" would suppress the + // spinner in the one case it exists for. + const wrap = buildEmbed( { readyState: 'complete', href: 'about:blank' } ); + + await runLoader(); + observers[ 0 ].intersect(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( true ); + } ); + + it( 'still covers an embed that is mid-flight', async () => { + const wrap = buildEmbed( { readyState: 'loading', href: 'http://example.test/embed.html' } ); + + await runLoader(); + observers[ 0 ].intersect(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( true ); + } ); + + it( 'falls back to the event flow when the document is unreadable', async () => { + // Cross-origin: we cannot know, so behave as before rather than guess. + const wrap = buildEmbed( { throws: true } ); + + await runLoader(); + observers[ 0 ].intersect(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( true ); + } ); + + it( 'still covers a frame that reports no document at all', async () => { + const wrap = buildEmbed( { readyState: null } ); + + await runLoader(); + observers[ 0 ].intersect(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( true ); + } ); +} ); + +describe( 'exelearning-embed-loader: without IntersectionObserver', () => { + it( 'arms the spinner immediately rather than never', async () => { + delete window.IntersectionObserver; + const wrap = buildEmbed( { readyState: 'loading', href: 'about:blank' } ); + + await runLoader(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( true ); + } ); + + it( 'still leaves a frame that already loaded uncovered', async () => { + delete window.IntersectionObserver; + const wrap = buildEmbed( { readyState: 'complete', href: 'http://example.test/embed.html' } ); + + await runLoader(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( false ); + } ); +} ); + +describe( 'exelearning-embed-loader: the normal ordering still works', () => { + it( 'covers the frame on intersection and clears it on load', async () => { + const wrap = buildEmbed( { readyState: 'loading', href: 'about:blank' } ); + const iframe = document.querySelector( '.exelearning-iframe' ); + + await runLoader(); + observers[ 0 ].intersect(); + expect( wrap.classList.contains( 'is-loading' ) ).toBe( true ); + + iframe.dispatchEvent( new window.Event( 'load' ) ); + await new Promise( ( resolve ) => setTimeout( resolve, 250 ) ); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( false ); + } ); + + it( 'does not arm the spinner after a load that arrived first', async () => { + const wrap = buildEmbed( { readyState: 'loading', href: 'about:blank' } ); + const iframe = document.querySelector( '.exelearning-iframe' ); + + await runLoader(); + iframe.dispatchEvent( new window.Event( 'load' ) ); + observers[ 0 ].intersect(); + + expect( wrap.classList.contains( 'is-loading' ) ).toBe( false ); + } ); +} ); diff --git a/tests/unit/ElpUploadBlockTest.php b/tests/unit/ElpUploadBlockTest.php index 24ca35c..3597023 100644 --- a/tests/unit/ElpUploadBlockTest.php +++ b/tests/unit/ElpUploadBlockTest.php @@ -752,6 +752,86 @@ public function test_block_iframe_has_shared_class() { $this->assertStringContainsString( 'exelearning-iframe', $result ); } + /** + * The preview iframe is wrapped in a loader that covers the blank frame + * while the package downloads and lays out. + */ + public function test_block_preview_has_loading_spinner() { + $attachment_id = $this->create_previewable_attachment( str_repeat( 'f', 40 ) ); + + $result = $this->block->render_block( array( 'attachmentId' => $attachment_id ) ); + + $this->assertStringContainsString( 'exelearning-embed-loader', $result ); + $this->assertStringContainsString( 'exelearning-embed-loader__spinner', $result ); + // Announced to assistive tech rather than silently spinning. + $this->assertStringContainsString( 'role="status"', $result ); + $this->assertStringContainsString( 'aria-live="polite"', $result ); + // The loader ships as one enqueued asset, so no copy of its behavior is + // inlined into the block. Asserted against its own distinctive tokens + // rather than "contains no