@lumx/react 2.1.8 → 2.1.9-alpha-thumbnail

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/esm/_internal/List2.js.map +1 -1
  2. package/esm/_internal/Slider2.js +21 -2
  3. package/esm/_internal/Slider2.js.map +1 -1
  4. package/esm/_internal/Thumbnail2.js +63 -764
  5. package/esm/_internal/Thumbnail2.js.map +1 -1
  6. package/esm/_internal/avatar.js +0 -3
  7. package/esm/_internal/avatar.js.map +1 -1
  8. package/esm/_internal/comment-block.js +0 -3
  9. package/esm/_internal/comment-block.js.map +1 -1
  10. package/esm/_internal/image-block.js +0 -3
  11. package/esm/_internal/image-block.js.map +1 -1
  12. package/esm/_internal/link-preview.js +0 -3
  13. package/esm/_internal/link-preview.js.map +1 -1
  14. package/esm/_internal/mdi.js +2 -2
  15. package/esm/_internal/mdi.js.map +1 -1
  16. package/esm/_internal/mosaic.js +0 -3
  17. package/esm/_internal/mosaic.js.map +1 -1
  18. package/esm/_internal/post-block.js +0 -3
  19. package/esm/_internal/post-block.js.map +1 -1
  20. package/esm/_internal/slider.js +1 -2
  21. package/esm/_internal/slider.js.map +1 -1
  22. package/esm/_internal/thumbnail.js +0 -3
  23. package/esm/_internal/thumbnail.js.map +1 -1
  24. package/esm/_internal/user-block.js +0 -2
  25. package/esm/_internal/user-block.js.map +1 -1
  26. package/esm/index.js +2 -3
  27. package/esm/index.js.map +1 -1
  28. package/package.json +4 -4
  29. package/src/components/thumbnail/Thumbnail.stories.tsx +343 -59
  30. package/src/components/thumbnail/Thumbnail.test.tsx +6 -6
  31. package/src/components/thumbnail/Thumbnail.tsx +37 -34
  32. package/src/components/thumbnail/useFocusPoint.ts +18 -10
  33. package/src/components/thumbnail/useImageLoad.ts +23 -22
  34. package/src/hooks/useOnResize.ts +6 -0
  35. package/src/stories/knobs/image.ts +35 -3
  36. package/types.d.ts +2 -0
  37. package/esm/_internal/clamp.js +0 -22
  38. package/esm/_internal/clamp.js.map +0 -1
@@ -7,7 +7,6 @@ import React, {
7
7
  ReactNode,
8
8
  Ref,
9
9
  useRef,
10
- useState,
11
10
  } from 'react';
12
11
  import classNames from 'classnames';
13
12
 
@@ -15,10 +14,8 @@ import { AspectRatio, HorizontalAlignment, Icon, Size, Theme } from '@lumx/react
15
14
 
16
15
  import { Comp, GenericProps, getRootClassName, handleBasicClasses } from '@lumx/react/utils';
17
16
 
18
- import { mdiImageBrokenVariant } from '@lumx/icons';
19
- import { isInternetExplorer } from '@lumx/react/utils/isInternetExplorer';
17
+ import { mdiImageBroken } from '@lumx/icons';
20
18
  import { mergeRefs } from '@lumx/react/utils/mergeRefs';
21
- import { useFocusPoint } from '@lumx/react/components/thumbnail/useFocusPoint';
22
19
  import { useImageLoad } from '@lumx/react/components/thumbnail/useImageLoad';
23
20
  import { FocusPoint, ThumbnailSize, ThumbnailVariant } from './types';
24
21
 
@@ -50,6 +47,8 @@ export interface ThumbnailProps extends GenericProps {
50
47
  imgProps?: ImgHTMLProps;
51
48
  /** Reference to the native <img> element. */
52
49
  imgRef?: Ref<HTMLImageElement>;
50
+ /** Set to true to force the display of the loading skeleton. */
51
+ isLoading?: boolean;
53
52
  /** Size variant of the component. */
54
53
  size?: ThumbnailSize;
55
54
  /** Image loading mode. */
@@ -82,11 +81,18 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
82
81
  * Component default props.
83
82
  */
84
83
  const DEFAULT_PROPS: Partial<ThumbnailProps> = {
85
- fallback: mdiImageBrokenVariant,
84
+ fallback: mdiImageBroken,
86
85
  loading: 'lazy',
87
86
  theme: Theme.light,
88
87
  };
89
88
 
89
+ function getObjectPosition(aspectRatio: AspectRatio, focusPoint?: FocusPoint) {
90
+ if (aspectRatio === AspectRatio.original || (!focusPoint?.y && !focusPoint?.x)) return undefined;
91
+ const x = (((focusPoint?.x || 0) + 1) / 2) * 100;
92
+ const y = (((focusPoint?.y || 0) - 1) / 2) * 100;
93
+ return `${x}% ${y}%`;
94
+ }
95
+
90
96
  /**
91
97
  * Thumbnail component.
92
98
  *
@@ -98,7 +104,7 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
98
104
  const {
99
105
  align,
100
106
  alt,
101
- aspectRatio,
107
+ aspectRatio = AspectRatio.original,
102
108
  badge,
103
109
  className,
104
110
  crossOrigin,
@@ -108,22 +114,22 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
108
114
  image,
109
115
  imgProps,
110
116
  imgRef: propImgRef,
117
+ isLoading: isLoadingProp,
111
118
  loading,
112
119
  size,
113
120
  theme,
114
121
  variant,
115
122
  linkProps,
116
123
  linkAs,
124
+ showSkeletonLoading = true,
117
125
  ...forwardedProps
118
126
  } = props;
119
127
  const imgRef = useRef<HTMLImageElement>(null);
120
128
 
121
129
  // Image loading state.
122
- const loadingState = useImageLoad(imgRef);
130
+ const loadingState = useImageLoad(image, imgRef);
131
+ const isLoading = isLoadingProp || loadingState === 'isLoading';
123
132
  const hasError = loadingState === 'hasError';
124
- const isLoading = loadingState === 'isLoading';
125
-
126
- const [wrapper, setWrapper] = useState<HTMLElement>();
127
133
 
128
134
  const isLink = Boolean(linkProps?.href || linkAs);
129
135
  const isButton = !!forwardedProps.onClick;
@@ -138,13 +144,10 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
138
144
  Wrapper = 'button';
139
145
  }
140
146
 
141
- // Update img style according to focus point and aspect ratio.
142
- const style = useFocusPoint({ image, focusPoint, aspectRatio, imgRef, loadingState, wrapper });
143
-
144
147
  return (
145
148
  <Wrapper
146
149
  {...wrapperProps}
147
- ref={mergeRefs(setWrapper, ref) as any}
150
+ ref={ref}
148
151
  className={classNames(
149
152
  linkProps?.className,
150
153
  className,
@@ -156,42 +159,42 @@ export const Thumbnail: Comp<ThumbnailProps> = forwardRef((props, ref) => {
156
159
  theme,
157
160
  variant,
158
161
  isClickable,
162
+ hasError,
163
+ isLoading: showSkeletonLoading && isLoading,
159
164
  hasBadge: !!badge,
160
165
  }),
161
- isLoading && wrapper?.getBoundingClientRect()?.height && 'lumx-color-background-dark-L6',
162
166
  fillHeight && `${CLASSNAME}--fill-height`,
163
167
  )}
164
168
  >
165
- <div
166
- className={`${CLASSNAME}__background`}
167
- style={{
168
- ...style?.wrapper,
169
- // Remove from layout if image not loaded correctly (use fallback)
170
- display: hasError ? 'none' : undefined,
171
- // Hide while loading.
172
- visibility: isLoading ? 'hidden' : undefined,
173
- }}
174
- >
169
+ <div className={`${CLASSNAME}__background`}>
175
170
  <img
176
171
  {...imgProps}
177
172
  style={{
178
173
  ...imgProps?.style,
179
- ...style?.image,
174
+ //
175
+ //display: hasError && (!imgProps?.width || !imgProps?.height) ? 'none' : undefined,
176
+ // Hide while loading.
177
+ visibility: hasError || (hasError && isLoading) ? 'hidden' : undefined,
178
+ // Focus point.
179
+ objectPosition: getObjectPosition(aspectRatio, focusPoint),
180
180
  }}
181
181
  ref={mergeRefs(imgRef, propImgRef)}
182
- className={style?.image ? `${CLASSNAME}__focused-image` : `${CLASSNAME}__image`}
183
- crossOrigin={crossOrigin && !isInternetExplorer() ? crossOrigin : undefined}
182
+ className={classNames(`${CLASSNAME}__image`, isLoading && `${CLASSNAME}__image--is-loading`)}
183
+ crossOrigin={crossOrigin}
184
184
  src={image}
185
185
  alt={alt}
186
186
  loading={loading}
187
187
  />
188
+ {!isLoading && hasError && (
189
+ <div className={`${CLASSNAME}__fallback`}>
190
+ {typeof fallback === 'string' ? (
191
+ <Icon icon={fallback} size={Size.xxs} theme={theme} />
192
+ ) : (
193
+ fallback
194
+ )}
195
+ </div>
196
+ )}
188
197
  </div>
189
- {hasError &&
190
- (typeof fallback === 'string' ? (
191
- <Icon className={`${CLASSNAME}__fallback`} icon={fallback} size={size || Size.m} theme={theme} />
192
- ) : (
193
- <div className={`${CLASSNAME}__fallback`}>{fallback}</div>
194
- ))}
195
198
  {badge &&
196
199
  React.cloneElement(badge, { className: classNames(`${CLASSNAME}__badge`, badge.props.className) })}
197
200
  </Wrapper>
@@ -32,15 +32,19 @@ type Sizes = {
32
32
 
33
33
  function calculateSizes(
34
34
  imageElement?: HTMLImageElement | null | undefined,
35
+ imageWidthProp?: number,
36
+ imageHeightProp?: number,
35
37
  parentElement?: HTMLElement | null,
36
38
  aspectRatio?: AspectRatio,
37
39
  ): Sizes | undefined {
38
- if (!imageElement || !parentElement || !aspectRatio || aspectRatio === AspectRatio.original) return undefined;
39
- const { naturalWidth: imgWidth, naturalHeight: imgHeight } = imageElement || { naturalWidth: 0, naturalHeight: 0 };
40
- const { width: containerWidth, height: containerHeight } = parentElement?.getBoundingClientRect() || {
41
- width: 0,
42
- height: 0,
43
- };
40
+ const imgWidth = imageElement?.naturalWidth || imageWidthProp;
41
+ const imgHeight = imageElement?.naturalHeight || imageHeightProp;
42
+ if (!imgHeight || !imgWidth || !parentElement || !aspectRatio || aspectRatio === AspectRatio.original) {
43
+ return undefined;
44
+ }
45
+ const rect = parentElement?.getBoundingClientRect();
46
+ const containerWidth = Math.ceil(rect?.width || 0);
47
+ const containerHeight = Math.ceil(rect?.height || 0);
44
48
  return { imgWidth, imgHeight, containerWidth, containerHeight, aspectRatio };
45
49
  }
46
50
 
@@ -90,6 +94,7 @@ function calculateImageStyle(sizes: Sizes, point: Required<FocusPoint>): Styles
90
94
  // Minimize image while still filling space
91
95
  if (sizes.imgWidth > sizes.containerWidth && sizes.imgHeight > sizes.containerHeight) {
92
96
  image[widthRatio > heightRatio ? 'maxHeight' : 'maxWidth'] = '100%';
97
+ image[widthRatio > heightRatio ? 'maxWidth' : 'maxHeight'] = 'none';
93
98
  }
94
99
 
95
100
  if (widthRatio > heightRatio) {
@@ -109,13 +114,15 @@ function calculateImageStyle(sizes: Sizes, point: Required<FocusPoint>): Styles
109
114
  */
110
115
  export const useFocusPoint = (options: {
111
116
  image: string;
117
+ imageWidthProp?: number;
118
+ imageHeightProp?: number;
112
119
  focusPoint?: FocusPoint;
113
120
  aspectRatio?: AspectRatio;
114
121
  imgRef: RefObject<HTMLImageElement>;
115
122
  loadingState: LoadingState;
116
123
  wrapper?: HTMLElement;
117
124
  }): Styles | undefined => {
118
- const { image, aspectRatio, focusPoint, imgRef, loadingState, wrapper } = options;
125
+ const { image, imageWidthProp, imageHeightProp, aspectRatio, focusPoint, imgRef, loadingState, wrapper } = options;
119
126
 
120
127
  const point = parseFocusPoint(focusPoint);
121
128
 
@@ -128,12 +135,13 @@ export const useFocusPoint = (options: {
128
135
  const update = useMemo(
129
136
  () => {
130
137
  const updateFunction = () => {
131
- const sizes = calculateSizes(imgRef?.current, wrapper, aspectRatio);
138
+ const sizes = calculateSizes(imgRef?.current, imageWidthProp, imageHeightProp, wrapper, aspectRatio);
132
139
  if (!sizes || (isEqual(sizes, previousSizes.current) && isEqual(point, previousPoint.current))) {
133
140
  // Nothing changed.
134
141
  return;
135
142
  }
136
- setStyle(calculateImageStyle(sizes, point));
143
+ const newStyle = calculateImageStyle(sizes, point);
144
+ setStyle(newStyle);
137
145
  previousPoint.current = point;
138
146
  previousSizes.current = sizes;
139
147
  };
@@ -141,7 +149,7 @@ export const useFocusPoint = (options: {
141
149
  return updateFunction;
142
150
  },
143
151
  // eslint-disable-next-line react-hooks/exhaustive-deps
144
- [...Object.values(point), imgRef, wrapper, aspectRatio],
152
+ [...Object.values(point), wrapper, aspectRatio],
145
153
  );
146
154
 
147
155
  // Update on image loaded.
@@ -1,39 +1,40 @@
1
- import { RefObject, useCallback, useEffect, useState } from 'react';
1
+ import { RefObject, useEffect, useState } from 'react';
2
2
 
3
3
  export type LoadingState = 'isLoading' | 'isLoaded' | 'hasError';
4
4
 
5
- export function useImageLoad(imgRef?: RefObject<HTMLImageElement>): LoadingState {
6
- const [state, setState] = useState<LoadingState>('isLoading');
7
-
8
- const update = useCallback(
9
- (event?: any) => {
10
- const img = imgRef?.current;
11
- if (!img || !img.complete) {
12
- setState('isLoading');
13
- return;
14
- }
5
+ function getState(img: HTMLImageElement | null | undefined, event?: Event) {
6
+ // Error event occurred or image loaded empty.
7
+ if (event?.type === 'error' || (img?.complete && (img?.naturalWidth === 0 || img?.naturalHeight === 0))) {
8
+ return 'hasError';
9
+ }
10
+ // Image is undefined or incomplete.
11
+ if (!img || !img.complete) {
12
+ return 'isLoading';
13
+ }
14
+ // Else loaded.
15
+ return 'isLoaded';
16
+ }
15
17
 
16
- if (event?.type === 'error' || (img.complete && img?.naturalWidth === 0)) {
17
- setState('hasError');
18
- return;
19
- }
18
+ export function useImageLoad(imageURL: string, imgRef?: RefObject<HTMLImageElement>): LoadingState {
19
+ const [state, setState] = useState<LoadingState>(getState(imgRef?.current));
20
20
 
21
- setState('isLoaded');
22
- },
23
- [imgRef],
24
- );
21
+ // Update state when changing image URL or DOM reference.
22
+ useEffect(() => {
23
+ setState(getState(imgRef?.current));
24
+ }, [imageURL, imgRef]);
25
25
 
26
+ // Listen to `load` and `error` event on image
26
27
  useEffect(() => {
27
28
  const img = imgRef?.current;
28
29
  if (!img) return undefined;
29
-
30
- update();
30
+ const update = (event?: Event) => setState(getState(img, event));
31
31
  img.addEventListener('load', update);
32
32
  img.addEventListener('error', update);
33
33
  return () => {
34
34
  img.removeEventListener('load', update);
35
35
  img.removeEventListener('error', update);
36
36
  };
37
- }, [update, imgRef, imgRef?.current?.src]);
37
+ }, [imgRef, imgRef?.current?.src]);
38
+
38
39
  return state;
39
40
  }
@@ -23,6 +23,12 @@ export function useOnResize(element: HTMLElement | Falsy, update: RefObject<Call
23
23
  return;
24
24
  }
25
25
 
26
+ // Do not update on first resize.
27
+ if (previousSize.current && previousSize.current.height <= 1) {
28
+ return;
29
+ }
30
+
31
+ // Do not update if size hasn't really changed.
26
32
  const { width, height } = entry.contentRect;
27
33
  if (previousSize.current?.width === width && previousSize.current?.height === height) {
28
34
  return;
@@ -6,18 +6,19 @@ const avatar3 = '/demo-assets/avatar3.jpg';
6
6
  const avatar4 = '/demo-assets/avatar4.jpg';
7
7
  const landscape1 = '/demo-assets/landscape1.jpg';
8
8
  const landscape2 = '/demo-assets/landscape2.jpg';
9
+ const landscape1s200 = '/demo-assets/landscape1-s200.jpg';
9
10
  const landscape3 = '/demo-assets/landscape3.jpg';
10
11
  const portrait1 = '/demo-assets/portrait1.jpg';
12
+ const portrait1s200 = '/demo-assets/portrait1-s200.jpg';
11
13
  const portrait2 = '/demo-assets/portrait2.jpg';
12
14
  const portrait3 = '/demo-assets/portrait3.jpg';
13
15
  const square1 = '/demo-assets/square1.jpg';
14
16
  const square2 = '/demo-assets/square2.jpg';
15
17
 
16
18
  export const AVATAR_IMAGES = { avatar1, avatar2, avatar3, avatar4 };
17
-
18
19
  export const SQUARE_IMAGES = { square1, square2 };
19
- export const LANDSCAPE_IMAGES = { landscape1, landscape2, landscape3 };
20
- export const PORTRAIT_IMAGES = { portrait1, portrait2, portrait3 };
20
+ export const LANDSCAPE_IMAGES = { landscape1, landscape1s200, landscape2, landscape3 };
21
+ export const PORTRAIT_IMAGES = { portrait1, portrait1s200, portrait2, portrait3 };
21
22
 
22
23
  export const IMAGES = { ...LANDSCAPE_IMAGES, ...PORTRAIT_IMAGES, ...SQUARE_IMAGES, ...AVATAR_IMAGES };
23
24
 
@@ -35,3 +36,34 @@ export const squareImageKnob = (name = 'Image', value = Object.values(SQUARE_IMA
35
36
 
36
37
  export const imageKnob = (name = 'Image', value = Object.values(IMAGES)[0], groupId?: string) =>
37
38
  select(name, IMAGES, value, groupId);
39
+
40
+ type Size = { width: number; height: number };
41
+
42
+ export const AVATAR_IMAGE_SIZES: Record<keyof typeof AVATAR_IMAGES, Size> = {
43
+ avatar1: { width: 128, height: 128 },
44
+ avatar2: { width: 150, height: 150 },
45
+ avatar3: { width: 128, height: 128 },
46
+ avatar4: { width: 128, height: 128 },
47
+ };
48
+ export const SQUARE_IMAGE_SIZES: Record<keyof typeof SQUARE_IMAGES, Size> = {
49
+ square1: { width: 72, height: 72 },
50
+ square2: { width: 300, height: 300 },
51
+ };
52
+ export const LANDSCAPE_IMAGE_SIZES: Record<keyof typeof LANDSCAPE_IMAGES, Size> = {
53
+ landscape1: { width: 800, height: 546 },
54
+ landscape1s200: { width: 200, height: 150 },
55
+ landscape2: { width: 800, height: 600 },
56
+ landscape3: { width: 640, height: 480 },
57
+ };
58
+ export const PORTRAIT_IMAGE_SIZES: Record<keyof typeof PORTRAIT_IMAGES, Size> = {
59
+ portrait1: { width: 275, height: 500 },
60
+ portrait1s200: { width: 200, height: 364 },
61
+ portrait2: { width: 350, height: 500 },
62
+ portrait3: { width: 300, height: 500 },
63
+ };
64
+ export const IMAGE_SIZES: Record<keyof typeof IMAGES, Size> = {
65
+ ...LANDSCAPE_IMAGE_SIZES,
66
+ ...PORTRAIT_IMAGE_SIZES,
67
+ ...SQUARE_IMAGE_SIZES,
68
+ ...AVATAR_IMAGE_SIZES,
69
+ };
package/types.d.ts CHANGED
@@ -1237,6 +1237,8 @@ export interface ThumbnailProps extends GenericProps {
1237
1237
  imgProps?: ImgHTMLProps;
1238
1238
  /** Reference to the native <img> element. */
1239
1239
  imgRef?: Ref<HTMLImageElement>;
1240
+ /** Set to true to force the display of the loading skeleton. */
1241
+ isLoading?: boolean;
1240
1242
  /** Size variant of the component. */
1241
1243
  size?: ThumbnailSize;
1242
1244
  /** Image loading mode. */
@@ -1,22 +0,0 @@
1
- /**
2
- * Clamp value in range.
3
- *
4
- * @param value Value to clamp.
5
- * @param min Minimum value.
6
- * @param max Maximum value.
7
- * @return Clamped value.
8
- */
9
- var clamp = function clamp(value, min, max) {
10
- if (value < min) {
11
- return min;
12
- }
13
-
14
- if (value > max) {
15
- return max;
16
- }
17
-
18
- return value;
19
- };
20
-
21
- export { clamp as c };
22
- //# sourceMappingURL=clamp.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"clamp.js","sources":["../../../src/utils/clamp.ts"],"sourcesContent":["/**\n * Clamp value in range.\n *\n * @param value Value to clamp.\n * @param min Minimum value.\n * @param max Maximum value.\n * @return Clamped value.\n */\nexport const clamp = (value: number, min: number, max: number): number => {\n if (value < min) {\n return min;\n }\n if (value > max) {\n return max;\n }\n return value;\n};\n"],"names":["clamp","value","min","max"],"mappings":"AAAA;;;;;;;;IAQaA,KAAK,GAAG,SAARA,KAAQ,CAACC,KAAD,EAAgBC,GAAhB,EAA6BC,GAA7B,EAAqD;AACtE,MAAIF,KAAK,GAAGC,GAAZ,EAAiB;AACb,WAAOA,GAAP;AACH;;AACD,MAAID,KAAK,GAAGE,GAAZ,EAAiB;AACb,WAAOA,GAAP;AACH;;AACD,SAAOF,KAAP;AACH;;;;"}