@hkdigital/lib-sveltekit 0.0.97 → 0.0.99

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.
@@ -8,9 +8,16 @@ export default class ImageVariantsLoader {
8
8
  /**
9
9
  * Set new optimal image variant or keep current
10
10
  *
11
- * @param {number} containerWidth
11
+ * @param {object} params
12
+ * @param {number} [params.containerWidth] Container width
13
+ * @param {number} [params.containerHeight] Container height
14
+ * @param {'cover'|'contain'|'fill'} [params.fit='contain'] Fit mode
12
15
  */
13
- updateOptimalImageMeta(containerWidth: number): void;
16
+ updateOptimalImageMeta({ containerWidth, containerHeight, fit }: {
17
+ containerWidth?: number;
18
+ containerHeight?: number;
19
+ fit?: "cover" | "contain" | "fill";
20
+ }): void;
14
21
  get loaded(): boolean;
15
22
  get variant(): import("./typedef.js").ImageMeta;
16
23
  /**
@@ -2,6 +2,8 @@
2
2
 
3
3
  // import * as expect from '../../../util/expect/index.js';
4
4
 
5
+ import { calculateEffectiveWidth } from '../../../util/image/index.js';
6
+
5
7
  import { untrack } from 'svelte';
6
8
 
7
9
  import ImageLoader from './ImageLoader.svelte.js';
@@ -21,57 +23,60 @@ export default class ImageVariantsLoader {
21
23
 
22
24
  #progress = $derived.by(() => {
23
25
  if (this.#imageLoader) {
24
- // const progress = this.#imageLoader.progress;
25
-
26
26
  return this.#imageLoader.progress;
27
27
  } else {
28
28
  return { bytesLoaded: 0, size: 0, loaded: false };
29
29
  }
30
30
  });
31
31
 
32
- #loaded = $derived(this.#progress.loaded);
32
+ #loaded = $derived.by(() => this.#progress?.loaded || false);
33
33
 
34
34
  /**
35
35
  * @param {ImageMeta[]} imagesMeta
36
36
  */
37
37
  constructor(imagesMeta, { devicePixelRatio = 1 } = {}) {
38
- // expect.notEmptyArray( imagesMeta );
39
-
40
38
  this.#devicePixelRatio = devicePixelRatio ?? 1;
41
-
42
- // Sort images meta by width ascending
43
39
  this.#imagesMeta = [...imagesMeta].sort((a, b) => a.width - b.width);
44
-
45
- $effect(() => {
46
- const variant = this.#imageVariant;
47
-
48
- if (variant) {
49
- // console.log('Load new variant', $state.snapshot(variant));
50
-
51
- // TODO: abort loading if imageLoader exists
52
-
53
- untrack(() => {
54
- const loader = (this.#imageLoader = new ImageLoader({
55
- url: variant.src
56
- }));
57
-
58
- loader.load();
59
- });
60
- }
61
- });
62
40
  }
63
41
 
64
42
  /**
65
43
  * Set new optimal image variant or keep current
66
44
  *
67
- * @param {number} containerWidth
45
+ * @param {object} params
46
+ * @param {number} [params.containerWidth] Container width
47
+ * @param {number} [params.containerHeight] Container height
48
+ * @param {'cover'|'contain'|'fill'} [params.fit='contain'] Fit mode
68
49
  */
69
- updateOptimalImageMeta(containerWidth) {
70
- const newVariant = this.getOptimalImageMeta(containerWidth);
50
+ updateOptimalImageMeta({ containerWidth, containerHeight, fit = 'contain' }) {
51
+ const baseImage = this.#imagesMeta[0];
52
+ const imageAspectRatio = baseImage.width / baseImage.height;
53
+
54
+ const effectiveWidth = calculateEffectiveWidth({
55
+ containerWidth,
56
+ containerHeight,
57
+ imageAspectRatio,
58
+ fit
59
+ });
60
+
61
+ const newVariant = this.getOptimalImageMeta(effectiveWidth);
71
62
 
72
- if (!newVariant || !this.#imageVariant || newVariant.width > this.#imageVariant.width) {
73
- // Only update imageVariant is width is larger
63
+ if (
64
+ !newVariant ||
65
+ !this.#imageVariant ||
66
+ newVariant.width > this.#imageVariant.width
67
+ ) {
74
68
  this.#imageVariant = newVariant;
69
+
70
+ // Create and start loader here directly when variant changes
71
+ if (this.#imageLoader?.initial) {
72
+ this.#imageLoader.unload();
73
+ }
74
+
75
+ this.#imageLoader = new ImageLoader({
76
+ imageMeta: newVariant
77
+ });
78
+
79
+ this.#imageLoader.load();
75
80
  }
76
81
  }
77
82
 
@@ -137,7 +142,9 @@ export default class ImageVariantsLoader {
137
142
 
138
143
  // Find the smallest image that's larger than our required width
139
144
 
140
- const optimal = imagesMeta.find((current) => current.width >= requiredWidth);
145
+ const optimal = imagesMeta.find(
146
+ (current) => current.width >= requiredWidth
147
+ );
141
148
 
142
149
  // Fall back to the largest image if nothing is big enough
143
150
  return optimal || imagesMeta[imagesMeta.length - 1];
@@ -0,0 +1,162 @@
1
+ <script>
2
+ import { onMount } from 'svelte';
3
+
4
+ /**
5
+ * @example
6
+ * import { EnhancedImage }
7
+ * from '$lib/components/EnhancedImage/index.js';
8
+ *
9
+ * <EnhancedImage
10
+ * classes="aspect-video max-h-svh w-full border-8 border-pink-500"
11
+ * src={NeonLightsOff}
12
+ * onload={() => {
13
+ * console.log('loaded');
14
+ * }}
15
+ * />
16
+ */
17
+
18
+ /**
19
+ * @type {{
20
+ * base?: string,
21
+ * bg?: string,
22
+ * classes?: string,
23
+ * overflow?: string,
24
+ * aspect?: string,
25
+ * fit?: string,
26
+ * position?: string,
27
+ * src: string | import('$lib/typedef/image.js').Picture,
28
+ * alt?: string,
29
+ * onload?: ( e: (Event|{ type: string, target: HTMLImageElement }) ) => void,
30
+ * onerror?: ( e: (Event|{ type: string, target: HTMLImageElement }) ) => void,
31
+ * loading?: string
32
+ * } & { [attr: string]: * }}
33
+ */
34
+ let {
35
+ // Style
36
+ base,
37
+ bg,
38
+ classes,
39
+
40
+ overflow = 'overflow-clip',
41
+ aspect,
42
+
43
+ fit = 'contain',
44
+ position = 'left top',
45
+
46
+ src,
47
+ alt = '',
48
+ onload,
49
+ onerror,
50
+ loading,
51
+
52
+ // Attributes
53
+ ...attrs
54
+ } = $props();
55
+
56
+ // let show = $state(false);
57
+
58
+ /** @type {HTMLDivElement|undefined} */
59
+ let imgBoxElem = $state();
60
+
61
+ /** @type {HTMLImageElement|undefined} */
62
+ let imgElem = $state();
63
+
64
+ /** @type {string | import('$lib/typedef/image.js').Picture} */
65
+ let src_;
66
+
67
+ $effect(() => {
68
+ if (src_ && src !== src_) {
69
+ throw new Error('Property [src] change is not supported');
70
+ }
71
+ });
72
+
73
+ let aspectStyle = $state('');
74
+
75
+ // > Event names
76
+ const LOAD = 'load';
77
+ const ERROR = 'error';
78
+
79
+ // > onMount
80
+
81
+ onMount(() => {
82
+ // show = true;
83
+
84
+ imgElem = imgBoxElem?.getElementsByTagName('img')[0];
85
+
86
+ if (!imgElem) {
87
+ throw new Error('Missing IMG element');
88
+ }
89
+
90
+ // > Auto set box aspect to same as image aspect if not set
91
+
92
+ if (!aspect) {
93
+ aspectStyle = `${imgElem.width / imgElem.height}`;
94
+ } else {
95
+ aspectStyle = '';
96
+ }
97
+
98
+ // > Register image onload and onerror handlers
99
+
100
+ /** @type {( e: Event ) => void} */
101
+ let onloadFn;
102
+
103
+ if (onload) {
104
+ if (imgElem?.complete) {
105
+ onload({ type: LOAD, target: imgElem });
106
+ } else {
107
+ onloadFn = onload;
108
+ imgElem?.addEventListener(LOAD, onloadFn);
109
+ }
110
+ }
111
+
112
+ /** @type {( e: Event ) => void} */
113
+ let onerrorFn;
114
+
115
+ if (onerror) {
116
+ onerrorFn = onerror;
117
+ imgElem?.addEventListener(ERROR, onerrorFn);
118
+ }
119
+
120
+ return () => {
121
+ if (onloadFn) {
122
+ imgElem?.removeEventListener(LOAD, onloadFn);
123
+ }
124
+
125
+ if (onerrorFn) {
126
+ imgElem?.removeEventListener(ERROR, onerrorFn);
127
+ }
128
+ };
129
+ });
130
+ </script>
131
+
132
+ <div
133
+ data-hk-enhanced-image
134
+ bind:this={imgBoxElem}
135
+ class="{base} {bg} {aspect} {overflow} {classes}"
136
+ style:--fit={fit}
137
+ style:--pos={position}
138
+ style:aspect-ratio={aspectStyle}
139
+ {...attrs}
140
+ >
141
+ <enhanced:img {src} {alt} />
142
+ </div>
143
+
144
+ <style>
145
+ [data-hk-enhanced-image],
146
+ :global([data-hk-enhanced-image] picture),
147
+ :global([data-hk-enhanced-image] img) {
148
+ display: block;
149
+ width: 100%;
150
+ height: 100%;
151
+ }
152
+ :global([data-hk-enhanced-image] picture),
153
+ :global([data-hk-enhanced-image] img) {
154
+ max-width: 100%;
155
+ max-height: 100%;
156
+ }
157
+
158
+ :global([data-hk-enhanced-image] img) {
159
+ object-fit: var(--fit);
160
+ object-position: var(--pos);
161
+ }
162
+ </style>
@@ -1,93 +1,26 @@
1
1
  <script>
2
- import { onMount } from 'svelte';
3
-
4
2
  import { ImageLoader } from '../../classes/svelte/image/index.js';
5
-
3
+ import { ImageVariantsLoader } from '../../classes/svelte/image/index.js';
6
4
  import { toSingleImageMeta } from '../../util/image/index.js';
7
5
 
8
6
  /**
9
- * @example
10
- * import { ImageBox } from '/path/to/ImageBox/index.js';
11
- *
12
- * // @note 'as=metadata' is set by the preset
13
- * import NeonLightsOff from '../../img/NeonLightsOff.jpg?preset=gradient';
14
- *
15
- * <!-- Example that fits in an outer-box -->
16
- *
17
- * <div class="outer-box">
18
- * <ImageBox image={ArmyGreen} fit="contain" position="center center" />
19
- * </div>
20
- *
21
- * <!-- Examples that has have width, height or aspect set -->
22
- *
23
- * <ImageBox
24
- * image={ArmyGreen}
25
- * fit="contain"
26
- * position="center center"
27
- * width="w-[200px]"
28
- * height="h-[200px]"
29
- * classes="border-8 border-green-500"
30
- * />
31
- *
32
- * <ImageBox
33
- * image={ArmyGreen}
34
- * fit="contain"
35
- * position="center center"
36
- * width="w-[200px]"
37
- * aspect="aspect-square"
38
- * classes="border-8 border-green-500"
39
- * />
40
- *
41
- * <ImageBox
42
- * image={ArmyGreen}
43
- * fit="contain"
44
- * position="center center"
45
- * height="h-[200px]"
46
- * aspect="aspect-square"
47
- * classes="border-8 border-green-500"
48
- * />
49
- *
50
- * <!-- Or hack it using !important -->
51
- *
52
- * <ImageBox
53
- * image={ArmyGreen}
54
- * fit="contain"
55
- * position="center center"
56
- * classes="!w-[200px] !h-[200px] border-8 border-green-500"
57
- * />
7
+ * @type {{
8
+ * base?: string,
9
+ * bg?: string,
10
+ * classes?: string,
11
+ * width?: string,
12
+ * height?: string,
13
+ * aspect?: string,
14
+ * overflow?: string,
15
+ * fit?: 'contain' | 'cover' | 'fill',
16
+ * position?: string,
17
+ * imageMeta: import('../../config/typedef.js').ImageMeta | import('../../config/typedef.js').ImageMeta[],
18
+ * imageLoader?: import('../../classes/svelte/image/index.js').ImageLoader,
19
+ * alt?: string,
20
+ * onProgress?: (progress: import('../../classes/svelte/network-loader/typedef.js').LoadingProgress) => void,
21
+ * [attr: string]: any
22
+ * }}
58
23
  */
59
-
60
- /**
61
- * @typedef {import('./typedef.js').ObjectFit} ObjectFit
62
- * @typedef {import('./typedef.js').ObjectPosition} ObjectPosition
63
- *
64
- * @typedef {import('../../classes/svelte/network-loader/typedef.js').LoadingProgress} LoadingProgress
65
- *
66
- * @typedef {import('../../config/typedef.js').ImageMeta} ImageMeta
67
- *
68
- * @typedef {Object} Props
69
- * @property {string} [base] - Base styling class
70
- * @property {string} [bg] - Background styling class
71
- * @property {string} [classes] - Additional CSS classes
72
- * @property {string} [width] - Width of the image container
73
- * @property {string} [height] - Height of the image container
74
- * @property {string} [aspect] - Aspect ratio of the image container
75
- * @property {string} [overflow] - Overflow behavior
76
- * @property {ObjectFit} [fit] - Object-fit property
77
- * @property {ObjectPosition} [position] - Object-position property
78
- *
79
- * @property {ImageMeta|ImageMeta[]} [imageMeta]
80
- * Image metadata, TODO: array of image metadata for responsive image
81
- *
82
- * @property {ImageLoader} [imageLoader]
83
- * Image loader
84
- *
85
- * @property {string} [alt] - Alternative text for the image
86
- * @property {() => LoadingProgress} [onProgress] - Progress callback function
87
- * @property {*} [attr] - Additional arbitrary attributes
88
- */
89
-
90
- /** @type {Props} */
91
24
  let {
92
25
  // Style
93
26
  base,
@@ -95,111 +28,101 @@
95
28
  classes,
96
29
  width,
97
30
  height,
98
- aspect,
31
+ aspect,
99
32
  overflow = 'overflow-clip',
100
33
 
101
- // Fitting and positioning of image in its container
34
+ // Fitting and positioning of image in its container
102
35
  fit = 'contain',
103
36
  position = 'left top',
104
37
 
105
- // Image meta data
38
+ // Image data
106
39
  imageMeta,
107
-
108
40
  imageLoader,
109
41
 
42
+ // Accessibility
110
43
  alt = '',
111
44
 
112
- // Attributes
45
+ // Events
46
+ onProgress,
47
+
48
+ // Additional attributes
113
49
  ...attrs
114
50
  } = $props();
115
51
 
116
- if( !imageMeta )
117
- {
118
- throw new Error('Missing [imageMeta]');
119
- }
120
-
121
- // let show = $state(false);
52
+ if (!imageMeta) {
53
+ throw new Error('Missing [imageMeta]');
54
+ }
122
55
 
123
56
  /** @type {HTMLDivElement|undefined} */
124
- let imgBoxElem = $state();
125
-
126
- /** @type {HTMLImageElement|undefined} */
127
- let imgElem = $state();
57
+ let containerElem = $state();
128
58
 
129
- let aspectStyle = $state('');
130
-
131
- // > Loading
59
+ let imageMeta_ = $state();
60
+ let variantsLoader = $state();
61
+ let variantObjectUrl = $state(null);
62
+ let objectUrl = $state(null);
132
63
 
64
+ // For single image meta
133
65
  let metaWidth = $state(0);
134
66
  let metaHeight = $state(0);
135
67
 
136
- let imageMeta_ = $state();
68
+ /** @type {ImageLoader|undefined} */
69
+ let imageLoader_ = $state();
137
70
 
138
71
  $effect(() => {
139
- if( imageMeta )
140
- {
141
- imageMeta_ = toSingleImageMeta( imageMeta );
72
+ // Setup variants loader for responsive images
73
+ if (Array.isArray(imageMeta) && !imageLoader && !variantsLoader) {
74
+ variantsLoader = new ImageVariantsLoader(imageMeta, {
75
+ devicePixelRatio: window.devicePixelRatio
76
+ });
77
+ }
78
+ // Handle single image meta
79
+ else if (imageMeta && !variantsLoader) {
80
+ imageMeta_ = toSingleImageMeta(imageMeta);
142
81
  }
143
82
  });
144
83
 
84
+ // Handle progress reporting
145
85
  $effect(() => {
146
- //
147
- // Set meta width and height
148
- //
149
- if (imageMeta_) {
150
- if (imageMeta_.width) {
151
- metaWidth = imageMeta_.width;
152
- }
86
+ if (!onProgress) return;
153
87
 
154
- if (imageMeta_.height) {
155
- metaHeight = imageMeta_.height;
156
- }
88
+ // Report progress from variants loader
89
+ if (variantsLoader) {
90
+ onProgress(variantsLoader.progress);
91
+ }
92
+ // Report progress from single image loader
93
+ else if (imageLoader_) {
94
+ onProgress(imageLoader_.progress);
157
95
  }
158
96
  });
159
97
 
160
- /** @type {ImageLoader|undefined} */
161
- let imageLoader_ = $state();
98
+ $effect(() => {
99
+ if (imageMeta_) {
100
+ metaWidth = imageMeta_.width ?? 0;
101
+ metaHeight = imageMeta_.height ?? 0;
102
+ }
103
+ });
162
104
 
163
- $effect( () => {
164
- //
165
- // User supplied imageLoader instead of imageMeta
166
- //
167
- if( !imageMeta && imageLoader && !imageLoader_ )
168
- {
105
+ $effect(() => {
106
+ if (!imageMeta && imageLoader && !imageLoader_) {
169
107
  imageLoader_ = imageLoader;
170
108
  imageMeta_ = imageLoader.imageMeta;
171
109
  }
172
- } );
173
-
174
- /** @type {string|null} */
175
- let objectUrl = $state(null);
110
+ });
176
111
 
177
112
  $effect(() => {
178
- //
179
- // Create image loader
180
- //
181
113
  if (imageMeta_ && !imageLoader_) {
182
114
  imageLoader_ = new ImageLoader({ imageMeta: imageMeta_ });
183
115
  }
184
116
  });
185
117
 
186
118
  $effect(() => {
187
- //
188
- // Start loading if imageLoader_ is in state 'initial'
189
- //
190
- // TODO: implement lazy flag
191
- //
192
119
  if (imageLoader_?.initial) {
193
120
  imageLoader_.load();
194
121
  }
195
122
  });
196
123
 
197
124
  $effect(() => {
198
- //
199
- // Get objectUrl when the image has finished loading
200
- //
201
- if (imageLoader_.loaded) {
202
- // @ts-ignore
125
+ if (imageLoader_?.loaded) {
203
126
  objectUrl = imageLoader_.getObjectURL();
204
127
  }
205
128
 
@@ -211,26 +134,62 @@
211
134
  };
212
135
  });
213
136
 
137
+ $effect(() => {
138
+ if (!containerElem || !variantsLoader) return;
139
+
140
+ const resizeObserver = new ResizeObserver((entries) => {
141
+ for (const entry of entries) {
142
+ const { width, height } = entry.contentRect;
143
+ variantsLoader.updateOptimalImageMeta({
144
+ containerWidth: width,
145
+ containerHeight: height,
146
+ fit
147
+ });
148
+ }
149
+ });
150
+
151
+ resizeObserver.observe(containerElem);
152
+ return () => resizeObserver.disconnect();
153
+ });
154
+
155
+ $effect(() => {
156
+ if (variantsLoader?.loaded) {
157
+ variantObjectUrl = variantsLoader.getObjectURL();
158
+ }
159
+
160
+ return () => {
161
+ if (variantObjectUrl) {
162
+ URL.revokeObjectURL(variantObjectUrl);
163
+ variantObjectUrl = null;
164
+ }
165
+ };
166
+ });
214
167
  </script>
215
168
 
216
169
  <div
217
170
  data-image="box"
218
- bind:this={imgBoxElem}
171
+ bind:this={containerElem}
219
172
  class="{base} {bg} {aspect} {overflow} {width} {height} {classes}"
220
173
  style:--fit={fit}
221
174
  style:--pos={position}
222
- style:aspect-ratio={aspectStyle}
223
175
  style:width={width || (height && aspect) ? undefined : '100%'}
224
176
  style:height={height || (width && aspect) ? undefined : '100%'}
225
177
  {...attrs}
226
178
  >
227
- {#if metaWidth && metaHeight}
179
+ {#if variantsLoader?.loaded && variantObjectUrl}
180
+ <img
181
+ src={variantObjectUrl}
182
+ {alt}
183
+ width={variantsLoader.variant.width}
184
+ height={variantsLoader.variant.height}
185
+ />
186
+ {:else if objectUrl && metaWidth && metaHeight}
228
187
  <img src={objectUrl} {alt} width={metaWidth} height={metaHeight} />
229
188
  {/if}
230
189
  </div>
231
190
 
232
191
  <style>
233
- [data-image="box"] {
192
+ [data-image='box'] {
234
193
  max-width: 100%;
235
194
  max-height: 100%;
236
195
  }
@@ -1,60 +1,17 @@
1
1
  export default ImageBox;
2
2
  declare const ImageBox: import("svelte").Component<{
3
- /**
4
- * - Base styling class
5
- */
3
+ [attr: string]: any;
6
4
  base?: string;
7
- /**
8
- * - Background styling class
9
- */
10
5
  bg?: string;
11
- /**
12
- * - Additional CSS classes
13
- */
14
6
  classes?: string;
15
- /**
16
- * - Width of the image container
17
- */
18
7
  width?: string;
19
- /**
20
- * - Height of the image container
21
- */
22
8
  height?: string;
23
- /**
24
- * - Aspect ratio of the image container
25
- */
26
9
  aspect?: string;
27
- /**
28
- * - Overflow behavior
29
- */
30
10
  overflow?: string;
31
- /**
32
- * - Object-fit property
33
- */
34
- fit?: import("./typedef.js").ObjectFit;
35
- /**
36
- * - Object-position property
37
- */
38
- position?: import("./typedef.js").ObjectPosition;
39
- /**
40
- * Image metadata, TODO: array of image metadata for responsive image
41
- */
42
- imageMeta?: import("../../config/typedef.js").ImageMeta | import("../../config/typedef.js").ImageMeta[];
43
- /**
44
- * Image loader
45
- */
46
- imageLoader?: ImageLoader;
47
- /**
48
- * - Alternative text for the image
49
- */
11
+ fit?: "contain" | "cover" | "fill";
12
+ position?: string;
13
+ imageMeta: import("../../config/typedef.js").ImageMeta | import("../../config/typedef.js").ImageMeta[];
14
+ imageLoader?: import("../../classes/svelte/image/index.js").ImageLoader;
50
15
  alt?: string;
51
- /**
52
- * - Progress callback function
53
- */
54
- onProgress?: () => import("../../classes/svelte/network-loader/typedef.js").LoadingProgress;
55
- /**
56
- * - Additional arbitrary attributes
57
- */
58
- attr?: any;
16
+ onProgress?: (progress: import("../../classes/svelte/network-loader/typedef.js").LoadingProgress) => void;
59
17
  }, {}, "">;
60
- import { ImageLoader } from '../../classes/svelte/image/index.js';