, HTMLImageElement> {
8 | src: string;
9 | alt: string;
10 | offset?: number;
11 | quality?: number;
12 | setRef?: (elem: HTMLImageElement | null) => void;
13 | }
14 |
15 | export class Image extends Component
{
16 |
17 | public static isShowDiagnostic: boolean = false;
18 |
19 | public static controlPoints: number[] = [160, 320, 640, 1280, 1920];
20 |
21 | /**
22 | * Image optimizer will take source url for show image.
23 | *
24 | * This is required when working on localhost.
25 | * Because a remote microservice cannot make requests to your localhost.
26 | *
27 | * Example:
28 | * Image.isUseSourceUrl = process.env.NODE_ENV !== "production";
29 | */
30 | public static isUseSourceUrl: boolean = false;
31 |
32 | /**
33 | * Image optimizer will change origin to other domain.
34 | *
35 | * This is necessary when you developing locally, this will take pictures from the DEV server by CORS.
36 | *
37 | * Also, this parameter must be specified if the component works in the microfront.
38 | * Then you need to specify the address to the server with pictures.
39 | *
40 | * Example:
41 | * Image.imgOrigin = https://tb.mts.ru
42 | *
43 | */
44 | public static imgOrigin?: string = void 0;
45 |
46 | private static isAvif: boolean | null = null;
47 |
48 | private static isWebP: boolean | null = null;
49 |
50 | public resultUrl: string = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
51 |
52 | public resizeCheckTimeout: number = 0;
53 |
54 | public thisComponent: HTMLImageElement | null = null;
55 |
56 | protected sourceUrl: string = "";
57 |
58 | protected readonly extensionsRegexp: RegExp = /\.\w+$/u;
59 |
60 | protected readonly windowResizeHandler: EventListenerOrEventListenerObject;
61 |
62 | protected lastOptimalSize: number = 0;
63 |
64 | /**
65 | * Serves to prevent recursion when resizing images to determine the optimal size.
66 | * This recursion can be caught with poor layout that does not take into account the scaling of images.
67 | */
68 | protected checks: number = 0;
69 |
70 | public constructor (props: P) {
71 | super(props);
72 |
73 | this.windowResizeHandler = (): void => this.onWindowResize();
74 | }
75 |
76 | public componentDidMount (): void {
77 | this.shouldComponentUpdate(this.props);
78 | window.addEventListener("resize", this.windowResizeHandler);
79 | }
80 |
81 | public shouldComponentUpdate (props: P): boolean {
82 | if (this.sourceUrl !== props.src) {
83 | this.sourceUrl = props.src;
84 | this.checks = 0;
85 |
86 | // Raf for correct image position after redraw outer components
87 | requestAnimationFrame(() => this.checkImage());
88 | }
89 | return true;
90 | }
91 |
92 | public componentWillUnmount (): void {
93 | window.removeEventListener("resize", this.windowResizeHandler);
94 | }
95 |
96 | public render (): JSX.Element {
97 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
98 | const {offset, quality, setRef, src, alt, ...props} = this.props;
99 |
100 | return (
101 |
requestAnimationFrame(() => this.checkImage())}
106 | ref={(elem: HTMLImageElement | null): void => {
107 | this.thisComponent = elem;
108 | this.props.setRef?.(elem);
109 | }}
110 | src={this.resultUrl}
111 | />
112 | );
113 | }
114 |
115 | protected async checkImage (isResize: boolean = false): Promise {
116 | if (this.checks > 1) {
117 | return;
118 | }
119 | this.checks += 1;
120 |
121 | if (Image.isUseSourceUrl && this.thisComponent) {
122 | this.thisComponent.src = this.sourceUrl;
123 | return;
124 | }
125 |
126 | if (Image.isAvif === null || Image.isWebP === null) {
127 | await this.initImageFormats();
128 | }
129 |
130 | this.processImage(isResize);
131 | }
132 |
133 | protected processImage (isResize: boolean): void {
134 | const containerSize = this.getContainerSize();
135 | const optimalSize = this.getOptimalSize(containerSize);
136 | this.showWarningOnResize(isResize, optimalSize);
137 | const url = this.makeResultUrl(optimalSize);
138 | this.applyResultUrl(url);
139 | this.showResultInLog(containerSize, optimalSize);
140 | }
141 |
142 | protected onWindowResize (): void {
143 | if (this.resizeCheckTimeout) {
144 | clearTimeout(this.resizeCheckTimeout);
145 | }
146 |
147 | this.resizeCheckTimeout = window.setTimeout(() => {
148 | this.checks = 0;
149 | this.checkImage(true);
150 | }, 500);
151 | }
152 |
153 | protected getContainerSize (): number {
154 | let containerSize: number = 0;
155 |
156 | let element: HTMLElement | null = this.thisComponent;
157 |
158 | while (containerSize < 2 && element) {
159 | containerSize = element.getBoundingClientRect().width ||
160 | Number.parseFloat(getComputedStyle(element).width) || 0;
161 | element = element.parentElement;
162 | }
163 |
164 | return containerSize * window.devicePixelRatio;
165 | }
166 |
167 | protected getOptimalSize (containerSize: number): number {
168 | let index: number = Image.controlPoints
169 | .findIndex((width: number) => containerSize <= width);
170 |
171 | // If bigger then maximum size or NaN
172 | if (index < 0) {
173 | index = Image.controlPoints.length - 1;
174 | }
175 |
176 | // Make manual more or less size
177 | index += this.props.offset ?? 0;
178 |
179 | // If offset make out of boundary
180 | if (index < 0) {
181 | index = 0;
182 | } else if (index >= Image.controlPoints.length) {
183 | index = Image.controlPoints.length - 1;
184 | }
185 |
186 | return Image.controlPoints[index];
187 | }
188 |
189 | protected makeResultUrl (optimalSize: number): URL {
190 | const sourceUrl = new URL(
191 | this.sourceUrl,
192 | Image.imgOrigin ?? location.origin
193 | );
194 |
195 | const url = new URL("/optimizer/optimize", location.origin);
196 | url.searchParams.set("src", sourceUrl.toString());
197 | url.searchParams.set("size", String(optimalSize));
198 |
199 | if (typeof this.props.quality === "number") {
200 | url.searchParams.set("quality", String(this.props.quality));
201 | }
202 |
203 | const format = this.extractImageFormat(sourceUrl.pathname);
204 | url.searchParams.set("format", format);
205 |
206 | return url;
207 | }
208 |
209 | protected extractImageFormat (path: string): string {
210 | let format = "";
211 |
212 | const match: RegExpExecArray | null = this.extensionsRegexp.exec(path);
213 |
214 | if (Image.isAvif === true) {
215 | format = "avif";
216 | } else if (Image.isWebP === true) {
217 | format = "webp";
218 | } else {
219 | format = String(match?.[0])
220 | .replace(".", "")
221 | .replace("jpg", "jpeg");
222 | }
223 |
224 | return format;
225 | }
226 |
227 | protected applyResultUrl (url: URL): void {
228 | const resultUrl = url.toString();
229 | if (this.resultUrl !== resultUrl && this.thisComponent) {
230 | this.resultUrl = resultUrl;
231 | this.thisComponent.src = this.resultUrl;
232 | }
233 | }
234 |
235 | /**
236 | * This function should motivate the developer to make a layout that does not cause resizing.
237 | *
238 | * @param {boolean} isResize
239 | * @param {number} optimalSize
240 | */
241 | protected showWarningOnResize (isResize: boolean, optimalSize: number): void {
242 | if (
243 | !isResize &&
244 | this.lastOptimalSize >= 0 &&
245 | this.lastOptimalSize !== optimalSize
246 | ) {
247 | // eslint-disable-next-line no-console
248 | console.warn(
249 | "New image size",
250 | this.lastOptimalSize,
251 | optimalSize,
252 | this.sourceUrl
253 | );
254 | }
255 | this.lastOptimalSize = optimalSize;
256 | }
257 |
258 | protected showResultInLog (containerSize: number, optimalSize: number): void {
259 | if (process.env.NODE_ENV !== "production" && Image.isShowDiagnostic) {
260 | // eslint-disable-next-line no-console
261 | console.log([
262 | "🏄 Image optimization:",
263 | `Container size ${containerSize}px,`,
264 | `optimal size ${optimalSize},`,
265 | `image ${this.sourceUrl}`
266 | ].join(" "));
267 | }
268 | }
269 |
270 | protected async initImageFormats (): Promise {
271 | const format = await getFormatFeatures();
272 |
273 | if (Image.isAvif === null || Image.isWebP === null) {
274 | Image.isAvif = format === "avif";
275 | Image.isWebP = format === "webp";
276 | }
277 | }
278 |
279 | }
280 |
--------------------------------------------------------------------------------
/src/helpers/check-avif-feature.ts:
--------------------------------------------------------------------------------
1 |
2 | export const checkAvifFeature = (): Promise => new Promise((resolve: (result: boolean) => void) => {
3 | const img = new Image();
4 | img.onload = (): void => {
5 | const result = (img.width > 0) && (img.height > 0);
6 | resolve(result);
7 | };
8 | img.onerror = (): void => {
9 | resolve(false);
10 | };
11 | // eslint-disable-next-line max-len
12 | img.src = "data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAADnBpdG0AAAAAAAEAAAAeaWxvYwAAAABEAAABAAEAAAABAAABGgAAAB0AAAAoaWluZgAAAAAAAQAAABppbmZlAgAAAAABAABhdjAxQ29sb3IAAAAAamlwcnAAAABLaXBjbwAAABRpc3BlAAAAAAAAAAIAAAACAAAAEHBpeGkAAAAAAwgICAAAAAxhdjFDgQ0MAAAAABNjb2xybmNseAACAAIAAYAAAAAXaXBtYQAAAAAAAAABAAEEAQKDBAAAACVtZGF0EgAKCBgANogQEAwgMg8f8D///8WfhwB8+ErK42A=";
13 | });
14 |
--------------------------------------------------------------------------------
/src/helpers/check-webp-feature.ts:
--------------------------------------------------------------------------------
1 | const kTestImages = {
2 | lossy: "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA",
3 | lossless: "UklGRhoAAABXRUJQVlA4TA0AAAAvAAAAEAcQERGIiP4HAA==",
4 | alpha: "UklGRkoAAABXRUJQVlA4WAoAAAAQAAAAAAAAAAAAQUxQSAwAAAARBxAR/Q9ERP8DAABWUDggGAAAABQBAJ0BKgEAAQAAAP4AAA3AAP7mtQAAAA==",
5 | animation: "UklGRlIAAABXRUJQVlA4WAoAAAASAAAAAAAAAAAAQU5JTQYAAAD/////" +
6 | "AABBTk1GJgAAAAAAAAAAAAAAAAAAAGQAAABWUDhMDQAAAC8AAAAQBxAREYiI/gcA"
7 | };
8 |
9 | export const checkWebpFeature = (): Promise => new Promise((resolve: (result: boolean) => void) => {
10 | const img = new Image();
11 | img.onload = (): void => {
12 | const result = (img.width > 0) && (img.height > 0);
13 | resolve(result);
14 | };
15 | img.onerror = (): void => {
16 | resolve(false);
17 | };
18 | img.src = `data:image/webp;base64,${kTestImages.lossless}`;
19 | });
20 |
--------------------------------------------------------------------------------
/src/helpers/get-format-features.ts:
--------------------------------------------------------------------------------
1 |
2 | import {checkAvifFeature} from "./check-avif-feature.js";
3 | import {checkWebpFeature} from "./check-webp-feature.js";
4 |
5 | // eslint-disable-next-line @typescript-eslint/no-type-alias
6 | type FormatType = "avif" | "webp" | null;
7 |
8 | const promisesPool: ((value: FormatType) => void)[] = [];
9 |
10 | const resolvePromises = (format: FormatType): void => {
11 | promisesPool.forEach((resolve) => resolve(format));
12 | };
13 |
14 | const checkFormats = async (): Promise => {
15 | const isAvif = await checkAvifFeature();
16 | if (isAvif) {
17 | resolvePromises("avif");
18 | return;
19 | }
20 |
21 | const isWebp = await checkWebpFeature();
22 | if (isWebp) {
23 | resolvePromises("webp");
24 | return;
25 | }
26 |
27 | resolvePromises(null);
28 | };
29 |
30 | export const getFormatFeatures = (): Promise => {
31 | if (promisesPool.length === 0) {
32 | checkFormats();
33 | }
34 |
35 | return new Promise((resolve) => {
36 | promisesPool.push(resolve);
37 | });
38 | };
39 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // Components
2 | export * from "./components/Image.js";
3 |
4 | // Helpers
5 | export * from "./helpers/check-avif-feature.js";
6 | export * from "./helpers/check-webp-feature.js";
7 |
8 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "module": "ESNext",
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "strict": true,
8 | "skipLibCheck": true,
9 | "declaration": true,
10 | "jsx": "react-jsx",
11 | "outDir": "./dist/",
12 | },
13 | "include": [
14 | "./src/**/*"
15 | ],
16 | }
17 |
--------------------------------------------------------------------------------