From 4f1517af606f2737f2000c455beeaa057a488331 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 8 Sep 2025 09:54:16 +0200 Subject: [PATCH 1/7] chore(util): add a lazy loading module for `img` tags --- src/util/lazy-load-images.ts | 124 +++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/util/lazy-load-images.ts diff --git a/src/util/lazy-load-images.ts b/src/util/lazy-load-images.ts new file mode 100644 index 0000000000..5d4b3f60c9 --- /dev/null +++ b/src/util/lazy-load-images.ts @@ -0,0 +1,124 @@ +/** + * Global utilities for implementing image lazy loading across components. + * + * Strategy: + * 1. During render, components output (no real src). + * 2. After the DOM has been rendered, call observeLazyImages(root) with the component's + * host or shadow root to start observing any images having a data-src attribute. + * 3. When an observed image enters the viewport, its data-src is copied to src and the + * data attribute removed, letting the browser load it at that moment. + * + * This augments native loading="lazy" with an IntersectionObserver based mechanism + * to prevent the browser from preloading many off-screen images aggressively. + */ + +import { visit } from 'unist-util-visit'; +import type { Node } from 'unist'; +import type { Plugin, Transformer } from 'unified'; + +/** Attribute used internally to avoid observing the same image multiple times */ +const OBSERVED_ATTR = 'data-lazy-observed'; + +/** + * Observe images inside the provided root (Host element or shadow root) and lazily + * set their src when they become visible. + * + * Only images matching the selector `img[data-src]:not([data-lazy-observed])` are + * considered. After an image is scheduled for observation it gets the + * `data-lazy-observed` attribute so subsequent calls are cheap. + * + * @param root - The host element or shadow root to search within. + */ +export function observeLazyImages(root: HTMLElement | ShadowRoot) { + if (!root) { + return; + } + + const images = root.querySelectorAll( + 'img[data-src]:not([' + OBSERVED_ATTR + '])' + ); + if (images.length === 0) { + return; + } + + // Fallback for browsers without IntersectionObserver + if ((window as any).IntersectionObserver === undefined) { + for (const img of images) { + const dataSrc = img.dataset.src; + if (dataSrc) { + img.src = dataSrc; + delete img.dataset.src; + } + } + return; + } + + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + const img = entry.target as HTMLImageElement; + const dataSrc = img.dataset.src; + if (dataSrc) { + img.src = dataSrc; + delete img.dataset.src; + } + observer.unobserve(img); + } + } + }); + + for (const img of images) { + img.setAttribute(OBSERVED_ATTR, ''); + observer.observe(img); + } +} + +/** + * Unified.js plugin used by markdown parsing to convert tags + * into lazy-loadable markup by moving src -> data-src and adding loading="lazy". + * + * Exported here so it can be reused in any future parsing contexts. + * + * @param lazyLoadImages - Whether to enable the transformation. + */ +export function createLazyLoadImagesPlugin(lazyLoadImages = false): Plugin { + return (): Transformer => { + if (!lazyLoadImages) { + return (tree: Node) => tree; + } + + return (tree: Node) => { + visit(tree, 'element', (node: any) => { + if (node.tagName === 'img') { + node.properties = node.properties || {}; + node.properties.loading = 'lazy'; + + if (node.properties.src) { + // Keep the original src so native lazy loading still works. + // Duplicate into data-src so IntersectionObserver can still + // manage advanced strategies without breaking initial load. + node.properties['data-src'] = node.properties.src; + } + } + }); + + return tree; + }; + }; +} + +/** + * Convenience helper to prepare an element for lazy loading. + * Moves the provided src to data-src and sets loading="lazy". + * + * @param img - The image element to modify. + * @param src - The image URL. + */ +export function prepareLazyImage(img: HTMLImageElement, src: string) { + if (!img) { + return; + } + img.loading = 'lazy'; + img.dataset.src = src; + img.removeAttribute('src'); +} From d1882e15956edff4fdc00c3ed245dd326438cafb Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 8 Sep 2025 09:55:15 +0200 Subject: [PATCH 2/7] chore(markdown): use global util for lazy loading `img`s --- src/components/markdown/markdown-parser.ts | 2 +- src/components/markdown/markdown.tsx | 29 +++++++++------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/components/markdown/markdown-parser.ts b/src/components/markdown/markdown-parser.ts index f8c4bd34e0..3e0dc29eff 100644 --- a/src/components/markdown/markdown-parser.ts +++ b/src/components/markdown/markdown-parser.ts @@ -10,7 +10,7 @@ import { visit } from 'unist-util-visit'; import { sanitizeStyle } from './sanitize-style'; import { Node } from 'unist'; import { Schema } from 'rehype-sanitize/lib'; -import { createLazyLoadImagesPlugin } from './image-markdown-plugin'; +import { createLazyLoadImagesPlugin } from '../../util/lazy-load-images'; import { CustomElementDefinition } from '../../global/shared-types/custom-element.types'; import { createLinksPlugin } from './link-markdown-plugin'; diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx index 93b87f157e..3365195162 100644 --- a/src/components/markdown/markdown.tsx +++ b/src/components/markdown/markdown.tsx @@ -2,7 +2,7 @@ import { Component, h, Prop, Watch } from '@stencil/core'; import { markdownToHTML } from './markdown-parser'; import { globalConfig } from '../../global/config'; import { CustomElementDefinition } from '../../global/shared-types/custom-element.types'; -import { ImageIntersectionObserver } from './image-intersection-observer'; +import { observeLazyImages } from '../../util/lazy-load-images'; /** * The Markdown component receives markdown syntax @@ -58,7 +58,7 @@ export class Markdown { @Watch('value') public async textChanged() { try { - this.cleanupImageIntersectionObserver(); + this.resetObserverFlag(); const html = await markdownToHTML(this.value, { forceHardLineBreaks: true, @@ -68,22 +68,20 @@ export class Markdown { this.rootElement.innerHTML = html; - this.setupImageIntersectionObserver(); + this.applyLazyObserver(); } catch (error) { console.error(error); } } private rootElement: HTMLDivElement; - private imageIntersectionObserver: ImageIntersectionObserver | null = null; + private hasAppliedObserver: boolean = false; public async componentDidLoad() { this.textChanged(); } - public disconnectedCallback() { - this.cleanupImageIntersectionObserver(); - } + public disconnectedCallback() {} public render() { return [ @@ -94,18 +92,15 @@ export class Markdown { ]; } - private setupImageIntersectionObserver() { - if (this.lazyLoadImages) { - this.imageIntersectionObserver = new ImageIntersectionObserver( - this.rootElement - ); + private applyLazyObserver() { + if (!this.lazyLoadImages || this.hasAppliedObserver) { + return; } + observeLazyImages(this.rootElement.shadowRoot || this.rootElement); + this.hasAppliedObserver = true; } - private cleanupImageIntersectionObserver() { - if (this.imageIntersectionObserver) { - this.imageIntersectionObserver.disconnect(); - this.imageIntersectionObserver = null; - } + private resetObserverFlag() { + this.hasAppliedObserver = false; } } From c44cd0a06971cbdbd8d2bad3c05097623a0fa5c7 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 8 Sep 2025 09:55:25 +0200 Subject: [PATCH 3/7] chore(list): use global util for lazy loading `img`s --- src/components/list/list.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/list/list.tsx b/src/components/list/list.tsx index a93c44af1a..fb5ce242c7 100644 --- a/src/components/list/list.tsx +++ b/src/components/list/list.tsx @@ -14,6 +14,7 @@ import { Watch, } from '@stencil/core'; import { ListRenderer } from './list-renderer'; +import { observeLazyImages } from '../../util/lazy-load-images'; import { ListRendererConfig } from './list-renderer-config'; const { ACTION_EVENT } = listStrings; @@ -136,6 +137,11 @@ export class List { style={{ '--maxLinesSecondaryText': `${maxLinesSecondaryText}`, }} + ref={(el) => { + if (el) { + observeLazyImages(el.shadowRoot || el); + } + }} > {html} From 0397bee7fdd6539cfdc14e7bf656fef514433f14 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 8 Sep 2025 09:55:37 +0200 Subject: [PATCH 4/7] fixup! chore(list): use global util for lazy loading `img`s --- src/components/list/list-renderer.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/list/list-renderer.tsx b/src/components/list/list-renderer.tsx index 3d4bf09e65..ab3bc29c46 100644 --- a/src/components/list/list-renderer.tsx +++ b/src/components/list/list-renderer.tsx @@ -270,7 +270,14 @@ export class ListRenderer { return; } - return {image.alt}; + return ( + {image.alt} + ); } private renderDivider = () => { From 814a6334259cc63290ecf5fe3e21b88800745942 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 8 Sep 2025 09:55:50 +0200 Subject: [PATCH 5/7] chore(file-viewer): use global util for lazy loading `img`s --- src/components/file-viewer/file-viewer.tsx | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/file-viewer/file-viewer.tsx b/src/components/file-viewer/file-viewer.tsx index a61b1d70c7..23b50d32f2 100644 --- a/src/components/file-viewer/file-viewer.tsx +++ b/src/components/file-viewer/file-viewer.tsx @@ -7,6 +7,7 @@ import { Event, EventEmitter, Watch, + Host, } from '@stencil/core'; import { Languages } from '../date-picker/date.types'; import { ListItem } from '../list/list-item.types'; @@ -15,6 +16,7 @@ import { detectExtension } from './extension-mapping'; import { Fullscreen } from './fullscreen'; import { FileType, OfficeViewer } from './file-viewer.types'; import { LimelMenuCustomEvent } from '../../components'; +import { observeLazyImages } from '../../util/lazy-load-images'; /** * This is a smart component that automatically detects @@ -169,7 +171,17 @@ export class FileViewer { return ; } - return this.renderFileViewer(); + return ( + { + if (el) { + observeLazyImages(el.shadowRoot || el); + } + }} + > + {this.renderFileViewer()} + + ); } @Watch('url') @@ -211,7 +223,12 @@ export class FileViewer { private renderImage = () => { return [ this.renderButtons(), - {this.alt}, + {this.alt}, ]; }; From 66fe19c5333e8f9650d6b3242c280953d147cf0e Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 8 Sep 2025 09:56:25 +0200 Subject: [PATCH 6/7] chore(chip): use global util for lazy loading `img`s --- src/components/chip/chip.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/chip/chip.tsx b/src/components/chip/chip.tsx index c19748fc9f..0afbdda12d 100644 --- a/src/components/chip/chip.tsx +++ b/src/components/chip/chip.tsx @@ -18,6 +18,7 @@ import { } from '../../util/make-enter-clickable'; import translate from '../../global/translations'; import { BACKSPACE, DELETE } from '../../util/keycodes'; +import { observeLazyImages } from '../../util/lazy-load-images'; import { ChipType, Chip as OldChipInterface } from '../chip-set/chip.types'; import { Image } from '../../global/shared-types/image.types'; import { isEmpty } from 'lodash-es'; @@ -229,7 +230,14 @@ export class Chip implements ChipInterface { public render() { return ( - + { + if (el) { + observeLazyImages(el.shadowRoot || el); + } + }} + > {this.link ? this.renderAsLink() : this.renderAsButton()} ); @@ -296,7 +304,12 @@ export class Chip implements ChipInterface { if (!isEmpty(this.image)) { return ( - {this.image.alt} + {this.image.alt} ); } From c644a37e09ec3264a603faa5203dca9c30e80754 Mon Sep 17 00:00:00 2001 From: Kiarokh Moattar Date: Mon, 8 Sep 2025 09:56:46 +0200 Subject: [PATCH 7/7] chore(card): use global util for lazy loading `img`s --- src/components/card/card.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/card/card.tsx b/src/components/card/card.tsx index 48386df64d..58796ebdb2 100644 --- a/src/components/card/card.tsx +++ b/src/components/card/card.tsx @@ -14,6 +14,7 @@ import { getIconName } from '../icon/get-icon-props'; import { ListSeparator } from '../../global/shared-types/separator.types'; import { ActionBarItem } from '../action-bar/action-bar.types'; import { getMouseEventHandlers } from '../../util/3d-tilt-hover-effect'; +import { observeLazyImages } from '../../util/lazy-load-images'; /** * Card is a component that displays content about a single topic, @@ -114,6 +115,12 @@ export class Card { { + // Apply lazy loading after first render + if (el) { + observeLazyImages(el.shadowRoot || el); + } + }} >
{this.renderImage()} @@ -133,8 +140,14 @@ export class Card { if (!this.image) { return; } - - return {this.image.alt}; + return ( + {this.image.alt} + ); } private renderHeader() {