Skip to content
Draft
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
17 changes: 15 additions & 2 deletions src/components/card/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -114,6 +115,12 @@ export class Card {
<Host
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
ref={(el) => {
// Apply lazy loading after first render
if (el) {
observeLazyImages(el.shadowRoot || el);
}
}}
>
<section tabindex={this.clickable ? 0 : ''}>
{this.renderImage()}
Expand All @@ -133,8 +140,14 @@ export class Card {
if (!this.image) {
return;
}

return <img src={this.image.src} alt={this.image.alt} loading="lazy" />;
return (
<img
src={this.image.src}
data-src={this.image.src}
alt={this.image.alt}
loading="lazy"
/>
);
}

private renderHeader() {
Expand Down
17 changes: 15 additions & 2 deletions src/components/chip/chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -229,7 +230,14 @@ export class Chip implements ChipInterface {

public render() {
return (
<Host onClick={this.filterClickWhenDisabled}>
<Host
onClick={this.filterClickWhenDisabled}
ref={(el) => {
if (el) {
observeLazyImages(el.shadowRoot || el);
}
}}
>
{this.link ? this.renderAsLink() : this.renderAsButton()}
</Host>
);
Expand Down Expand Up @@ -296,7 +304,12 @@ export class Chip implements ChipInterface {

if (!isEmpty(this.image)) {
return (
<img src={this.image.src} alt={this.image.alt} loading="lazy" />
<img
src={this.image.src}
data-src={this.image.src}
alt={this.image.alt}
loading="lazy"
/>
);
}

Expand Down
21 changes: 19 additions & 2 deletions src/components/file-viewer/file-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -169,7 +171,17 @@ export class FileViewer {
return <limel-spinner size="x-small" limeBranded={false} />;
}

return this.renderFileViewer();
return (
<Host
ref={(el: any) => {
if (el) {
observeLazyImages(el.shadowRoot || el);
}
}}
>
{this.renderFileViewer()}
</Host>
);
}

@Watch('url')
Expand Down Expand Up @@ -211,7 +223,12 @@ export class FileViewer {
private renderImage = () => {
return [
this.renderButtons(),
<img src={this.fileUrl} alt={this.alt} loading="lazy" />,
<img
src={this.fileUrl}
data-src={this.fileUrl}
alt={this.alt}
loading="lazy"
/>,
];
};

Expand Down
9 changes: 8 additions & 1 deletion src/components/list/list-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,14 @@ export class ListRenderer {
return;
}

return <img src={image.src} alt={image.alt} loading="lazy" />;
return (
<img
src={image.src}
data-src={image.src}
alt={image.alt}
loading="lazy"
/>
);
}

private renderDivider = () => {
Expand Down
6 changes: 6 additions & 0 deletions src/components/list/list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -136,6 +137,11 @@ export class List {
style={{
'--maxLinesSecondaryText': `${maxLinesSecondaryText}`,
}}
ref={(el) => {
if (el) {
observeLazyImages(el.shadowRoot || el);
}
}}
>
{html}
</Host>
Expand Down
2 changes: 1 addition & 1 deletion src/components/markdown/markdown-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
29 changes: 12 additions & 17 deletions src/components/markdown/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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 [
Expand All @@ -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;
}
}
124 changes: 124 additions & 0 deletions src/util/lazy-load-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* Global utilities for implementing image lazy loading across components.
*
* Strategy:
* 1. During render, components output <img data-src="..." loading="lazy"> (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<HTMLImageElement>(
'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 <img src="..."> 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 <img> 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');
}
Loading