Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .distignore
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ sftp-config.json
AGENTS.md
EXELEARNING_CHANGES.md
playwright.config.*
vitest.config.*
scripts
cloudflare-worker
mkdocs.yml
Expand Down
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions assets/css/exelearning.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
140 changes: 140 additions & 0 deletions assets/js/exelearning-embed-loader.js
Original file line number Diff line number Diff line change
@@ -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();
}
}() );
30 changes: 29 additions & 1 deletion includes/class-elp-upload-block.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
);
}

/**
Expand Down Expand Up @@ -300,6 +314,15 @@ private function render_block_preview( $data, $download_html ) {
$html .= '<div class="exelearning-block-toolbar">' . $download_html . $fullscreen_html . '</div>';
}

// 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 .= '<div class="exelearning-embed-loader">';
$html .= sprintf(
'<iframe
src="%s"
Expand All @@ -314,6 +337,11 @@ class="exelearning-iframe"
$data['height'],
esc_attr( get_the_title( $data['attachment_id'] ) )
);
$html .= sprintf(
'<div class="exelearning-embed-loader__spinner" role="status" aria-live="polite">%s</div>',
esc_html__( 'Loading content…', 'exelearning' )
);
$html .= '</div>';

$html .= '</div>';

Expand Down
12 changes: 8 additions & 4 deletions languages/exelearning-ca.po
Original file line number Diff line number Diff line change
Expand Up @@ -459,26 +459,30 @@ msgstr "Aquest no és un fitxer eXeLearning (.elpx)."
msgid "Failed to create directory for extracted files."
msgstr "Error en crear el directori per als fitxers extrets."

#: includes/class-elp-upload-block.php:167
#: includes/class-elp-upload-block.php:181
msgid "Error: eXeLearning content not found"
msgstr "Error: contingut d'eXeLearning no trobat"

#: includes/class-elp-upload-block.php:258
#: includes/class-elp-upload-block.php:272
#: public/class-shortcodes.php:204
msgid "Download file"
msgstr "Descarrega el fitxer"

#: includes/class-elp-upload-block.php:271
#: includes/class-elp-upload-block.php:285
msgid "This eXeLearning content is a source file and cannot be previewed directly."
msgstr "Aquest contingut d'eXeLearning és un fitxer font i no es pot previsualitzar directament."

#: includes/class-elp-upload-block.php:295
#: includes/class-elp-upload-block.php:309
#: public/class-shortcodes.php:336
#: assets/js/elp-upload.js:501
#: assets/js/elp-upload.js:502
msgid "View fullscreen"
msgstr "Veure a pantalla completa"

#: includes/class-elp-upload-block.php:342
msgid "Loading content…"
msgstr "S'està carregant el contingut…"

#: includes/class-exelearning-editor.php:82
#: includes/class-exelearning-editor.php:83
msgid "eXeLearning Editor"
Expand Down
12 changes: 8 additions & 4 deletions languages/exelearning-ca_valencia.po
Original file line number Diff line number Diff line change
Expand Up @@ -459,26 +459,30 @@ msgstr "Este no és un fitxer eXeLearning (.elpx)."
msgid "Failed to create directory for extracted files."
msgstr "Error en crear el directori per als fitxers extrets."

#: includes/class-elp-upload-block.php:167
#: includes/class-elp-upload-block.php:181
msgid "Error: eXeLearning content not found"
msgstr "Error: contingut d'eXeLearning no trobat"

#: includes/class-elp-upload-block.php:258
#: includes/class-elp-upload-block.php:272
#: public/class-shortcodes.php:204
msgid "Download file"
msgstr "Descarregar el fitxer"

#: includes/class-elp-upload-block.php:271
#: includes/class-elp-upload-block.php:285
msgid "This eXeLearning content is a source file and cannot be previewed directly."
msgstr "Este contingut d'eXeLearning és un fitxer font i no es pot previsualitzar directament."

#: includes/class-elp-upload-block.php:295
#: includes/class-elp-upload-block.php:309
#: public/class-shortcodes.php:336
#: assets/js/elp-upload.js:501
#: assets/js/elp-upload.js:502
msgid "View fullscreen"
msgstr "Veure a pantalla completa"

#: includes/class-elp-upload-block.php:342
msgid "Loading content…"
msgstr "S'està carregant el contingut…"

#: includes/class-exelearning-editor.php:82
#: includes/class-exelearning-editor.php:83
msgid "eXeLearning Editor"
Expand Down
12 changes: 8 additions & 4 deletions languages/exelearning-de_DE.po
Original file line number Diff line number Diff line change
Expand Up @@ -459,26 +459,30 @@ msgstr "Dies ist keine eXeLearning-Datei (.elpx)."
msgid "Failed to create directory for extracted files."
msgstr "Fehler beim Erstellen des Verzeichnisses für extrahierte Dateien."

#: includes/class-elp-upload-block.php:167
#: includes/class-elp-upload-block.php:181
msgid "Error: eXeLearning content not found"
msgstr "Fehler: eXeLearning-Inhalt nicht gefunden"

#: includes/class-elp-upload-block.php:258
#: includes/class-elp-upload-block.php:272
#: public/class-shortcodes.php:204
msgid "Download file"
msgstr "Datei herunterladen"

#: includes/class-elp-upload-block.php:271
#: includes/class-elp-upload-block.php:285
msgid "This eXeLearning content is a source file and cannot be previewed directly."
msgstr "Dieser eXeLearning-Inhalt ist eine Quelldatei und kann nicht direkt in der Vorschau angezeigt werden."

#: includes/class-elp-upload-block.php:295
#: includes/class-elp-upload-block.php:309
#: public/class-shortcodes.php:336
#: assets/js/elp-upload.js:501
#: assets/js/elp-upload.js:502
msgid "View fullscreen"
msgstr "Vollbild anzeigen"

#: includes/class-elp-upload-block.php:342
msgid "Loading content…"
msgstr "Inhalt wird geladen…"

#: includes/class-exelearning-editor.php:82
#: includes/class-exelearning-editor.php:83
msgid "eXeLearning Editor"
Expand Down
Loading