From e808458dd552c7ca25b2fca0fc69f055c5834baf Mon Sep 17 00:00:00 2001 From: Stephan Kergomard Date: Tue, 19 May 2026 17:59:31 +0200 Subject: [PATCH] UI: Fix Loading of Optimal Image on Invisible See: https://mantis.ilias.de/view.php?id=47613 --- .../UI/resources/js/Image/dist/image.min.js | 2 +- .../UI/resources/js/Image/rollup.config.js | 6 ++- .../ILIAS/UI/resources/js/Image/src/image.js | 7 ++- .../js/Image/src/loadHighResolutionSource.js | 22 +++++++- .../Client/Image/loadHighResolutionSource.js | 52 ++++++++++++++++++- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/components/ILIAS/UI/resources/js/Image/dist/image.min.js b/components/ILIAS/UI/resources/js/Image/dist/image.min.js index 5ad247cca8e2..8f8c4074bf8e 100644 --- a/components/ILIAS/UI/resources/js/Image/dist/image.min.js +++ b/components/ILIAS/UI/resources/js/Image/dist/image.min.js @@ -12,4 +12,4 @@ * https://www.ilias.de * https://github.com/ILIAS-eLearning */ -!function(e,n){"use strict";e.UI=e.UI||{},e.UI.image=e.UI.image||{},e.UI.image.getImageElement=e=>function(e,n){const t=e.getElementById(n);return null===t?null:t instanceof e.defaultView.HTMLImageElement?t:t.querySelector("img")}(n,e),e.UI.image.loadHighResolutionSource=function(e,n){const t=function(e,n){let t=null,l=null;return e.forEach(((e,i)=>{i<=n&&i>l&&(l=i,t=e)})),t}(n,e.width);if(null!==t){const n=e.cloneNode();n.addEventListener("load",(()=>{e.replaceWith(n)})),n.src=t}}}(il,document); +!function(e,n,t){"use strict";function i(e,n,t){if(!n.checkVisibility()){return void new e(((o,l)=>{i(e,n,t),l.unobserve(n)}),{root:null,rootMargin:"0px",threshold:.1}).observe(n)}const o=function(e,n){let t=null,i=null;return e.forEach(((e,o)=>{o<=n&&o>i&&(i=o,t=e)})),t}(t,n.width);if(null!==o){const e=n.cloneNode();e.addEventListener("load",(()=>{n.replaceWith(e)})),e.src=o}}e.UI=e.UI||{},e.UI.image=e.UI.image||{},e.UI.image.getImageElement=e=>function(e,n){const t=e.getElementById(n);return null===t?null:t instanceof e.defaultView.HTMLImageElement?t:t.querySelector("img")}(n,e),e.UI.image.loadHighResolutionSource=(e,n)=>i(t,e,n)}(il,document,IntersectionObserver); diff --git a/components/ILIAS/UI/resources/js/Image/rollup.config.js b/components/ILIAS/UI/resources/js/Image/rollup.config.js index f4cffabad2c4..8df0f905d620 100755 --- a/components/ILIAS/UI/resources/js/Image/rollup.config.js +++ b/components/ILIAS/UI/resources/js/Image/rollup.config.js @@ -14,13 +14,14 @@ */ import terser from '@rollup/plugin-terser'; -import copyright from '../../../../../../scripts/Copyright-Checker/copyright'; -import preserveCopyright from '../../../../../../scripts/Copyright-Checker/preserveCopyright'; +import copyright from '../../../../../../scripts/Copyright-Checker/copyright.js'; +import preserveCopyright from '../../../../../../scripts/Copyright-Checker/preserveCopyright.js'; export default { external: [ 'il', 'document', + 'IntersectionObserver', ], input: './src/image.js', output: { @@ -30,6 +31,7 @@ export default { globals: { il: 'il', document: 'document', + IntersectionObserver: 'IntersectionObserver', }, plugins: [ terser({ diff --git a/components/ILIAS/UI/resources/js/Image/src/image.js b/components/ILIAS/UI/resources/js/Image/src/image.js index aec88fb29de4..86e5fffcae18 100755 --- a/components/ILIAS/UI/resources/js/Image/src/image.js +++ b/components/ILIAS/UI/resources/js/Image/src/image.js @@ -18,9 +18,14 @@ import il from 'il'; import document from 'document'; import getImageElement from './getImageElement'; import loadHighResolutionSource from './loadHighResolutionSource'; +import IntersectionObserver from 'IntersectionObserver'; il.UI = il.UI || {}; il.UI.image = il.UI.image || {}; il.UI.image.getImageElement = (imageId) => getImageElement(document, imageId); -il.UI.image.loadHighResolutionSource = loadHighResolutionSource; +il.UI.image.loadHighResolutionSource = (imageElement, highResDefinitions) => loadHighResolutionSource( + IntersectionObserver, + imageElement, + highResDefinitions, +); diff --git a/components/ILIAS/UI/resources/js/Image/src/loadHighResolutionSource.js b/components/ILIAS/UI/resources/js/Image/src/loadHighResolutionSource.js index b191d004d861..8bbaeedc481d 100755 --- a/components/ILIAS/UI/resources/js/Image/src/loadHighResolutionSource.js +++ b/components/ILIAS/UI/resources/js/Image/src/loadHighResolutionSource.js @@ -43,10 +43,30 @@ function determineBestSource(highResDefinitions, imageWidth) { /** * Loads the best fitting high resolution source for the given image element. * + * @param {IntersectionObserver} intersectionObserver * @param {HTMLImageElement} imageElement * @param {Map} highResDefinitions min-width in px => source mapping */ -export default function loadHighResolutionSource(imageElement, highResDefinitions) { +export default function loadHighResolutionSource( + intersectionObserver, + imageElement, + highResDefinitions, +) { + if (!imageElement.checkVisibility()) { + const visibilityObserver = new intersectionObserver( + (elements, observer) => { + loadHighResolutionSource(intersectionObserver, imageElement, highResDefinitions); + observer.unobserve(imageElement); + }, + { + root: null, + rootMargin: '0px', + threshold: 0.1, + }, + ); + visibilityObserver.observe(imageElement); + return; + } const optimalSource = determineBestSource(highResDefinitions, imageElement.width); if (optimalSource !== null) { const highResolutionImage = imageElement.cloneNode(); diff --git a/components/ILIAS/UI/tests/Client/Image/loadHighResolutionSource.js b/components/ILIAS/UI/tests/Client/Image/loadHighResolutionSource.js index 414c189cdfb7..b382cf12f272 100755 --- a/components/ILIAS/UI/tests/Client/Image/loadHighResolutionSource.js +++ b/components/ILIAS/UI/tests/Client/Image/loadHighResolutionSource.js @@ -15,7 +15,7 @@ * @author Thibeau Fuhrer */ -import { describe, it } from 'node:test'; +import { describe, it, mock } from 'node:test'; import { strict } from 'node:assert/strict'; import loadHighResolutionSource from '../../../resources/js/Image/src/loadHighResolutionSource.js'; @@ -58,11 +58,14 @@ describe('loadHighResolutionSource', () => { addEventListener(e, fn) { this.loader = fn; }, + checkVisibility() { + return true; + }, }; for (let width = 0, maxWidth = 35; width <= maxWidth; width += 5) { imageElement.width = width; - loadHighResolutionSource(imageElement, definitions); + loadHighResolutionSource(class {}, imageElement, definitions); // we manually need to call the "load" event, since this will // actually replace the element, which updates the src property @@ -75,4 +78,49 @@ describe('loadHighResolutionSource', () => { strict.equal(imageElement.src, expectedSources.get(width)); } }); + it('should initialize observer.', () => { + const initialSource = 'source_0'; + const sourceAbove10 = 'source_10'; + const sourceAbove20 = 'source_20'; + const sourceAbove30 = 'source_30'; + + // note the definitions are NOT ordered by min-width + const definitions = new Map([ + [10, sourceAbove10], + [30, sourceAbove30], + [20, sourceAbove20], + ]); + + const imageElement = { + width: 0, + loader: null, + src: initialSource, + cloneNode() { + return this; + }, + replaceWith(otherImage) { + this.src = otherImage.src; + }, + addEventListener(e, fn) { + this.loader = fn; + }, + checkVisibility() { + return false; + }, + }; + + const imageObserver = mock.fn(class { + initialized = false; + + constructor() { + this.initialized = true; + } + + observe() {} + }); + + loadHighResolutionSource(imageObserver, imageElement, definitions); + + strict.equal(imageObserver.mock.calls.length, 1); + }); });