@webstudio-is/image 0.1.0 → 0.15.0
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.
- package/lib/__generated__/image.props.json +573 -573
- package/lib/cjs/__generated__/image.props.json +573 -573
- package/lib/cjs/image-loaders.cjs +48 -33
- package/lib/cjs/image-optimize.cjs +101 -171
- package/lib/cjs/image.cjs +54 -22
- package/lib/cjs/index.cjs +31 -31
- package/lib/image-loaders.js +24 -27
- package/lib/image-optimize.js +83 -169
- package/lib/image.js +30 -15
- package/lib/index.js +8 -3
- package/package.json +9 -16
- package/src/__generated__/image.props.json +575 -0
- package/src/image-dev.stories.tsx +128 -0
- package/src/image-loaders.ts +47 -0
- package/{lib/image-optimize.test.js → src/image-optimize.test.ts} +122 -103
- package/{lib/cjs/image-optimize.d.ts → src/image-optimize.ts} +172 -19
- package/src/image.tsx +74 -0
- package/{lib/cjs/index.d.ts → src/index.ts} +0 -1
- package/lib/cjs/image-dev.stories.cjs +0 -77
- package/lib/cjs/image-dev.stories.d.ts +0 -35
- package/lib/cjs/image-dev.stories.d.ts.map +0 -1
- package/lib/cjs/image-loaders.d.ts +0 -14
- package/lib/cjs/image-loaders.d.ts.map +0 -1
- package/lib/cjs/image-optimize.d.ts.map +0 -1
- package/lib/cjs/image-optimize.test.cjs +0 -157
- package/lib/cjs/image-optimize.test.d.ts +0 -2
- package/lib/cjs/image-optimize.test.d.ts.map +0 -1
- package/lib/cjs/image.d.ts +0 -11
- package/lib/cjs/image.d.ts.map +0 -1
- package/lib/cjs/index.d.ts.map +0 -1
- package/lib/image-dev.stories.d.ts +0 -35
- package/lib/image-dev.stories.d.ts.map +0 -1
- package/lib/image-dev.stories.js +0 -65
- package/lib/image-loaders.d.ts +0 -14
- package/lib/image-loaders.d.ts.map +0 -1
- package/lib/image-optimize.d.ts +0 -107
- package/lib/image-optimize.d.ts.map +0 -1
- package/lib/image-optimize.test.d.ts +0 -2
- package/lib/image-optimize.test.d.ts.map +0 -1
- package/lib/image.d.ts +0 -11
- package/lib/image.d.ts.map +0 -1
- package/lib/index.d.ts +0 -5
- package/lib/index.d.ts.map +0 -1
- package/lib/tsconfig.tsbuildinfo +0 -1
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import warnOnce from "warn-once";
|
|
2
|
+
import { allSizes, type ImageLoader } from "./image-optimize";
|
|
3
|
+
|
|
4
|
+
export type CloudflareImageLoaderOptions = {
|
|
5
|
+
resizeOrigin?: string | null;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default image loader in case of no loader provided
|
|
10
|
+
* https://developers.cloudflare.com/images/image-resizing/url-format/
|
|
11
|
+
**/
|
|
12
|
+
export const cloudflareImageLoader: (
|
|
13
|
+
ops: CloudflareImageLoaderOptions | null
|
|
14
|
+
) => ImageLoader =
|
|
15
|
+
(loaderOptions) =>
|
|
16
|
+
({ width, src, quality }) => {
|
|
17
|
+
if (process.env.NODE_ENV !== "production") {
|
|
18
|
+
warnOnce(
|
|
19
|
+
allSizes.includes(width) === false,
|
|
20
|
+
"Width must be only from allowed values"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const options = `width=${width},quality=${quality},format=auto`;
|
|
25
|
+
// Cloudflare docs say that we don't need to urlencode the path params
|
|
26
|
+
const pathname = `/cdn-cgi/image/${options}/${src}`;
|
|
27
|
+
|
|
28
|
+
if (loaderOptions?.resizeOrigin != null) {
|
|
29
|
+
const url = new URL(pathname, loaderOptions.resizeOrigin);
|
|
30
|
+
return url.href;
|
|
31
|
+
} else {
|
|
32
|
+
return pathname;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Fake pseudo loader for local testing purposes
|
|
38
|
+
**/
|
|
39
|
+
export const localImageLoader: () => ImageLoader =
|
|
40
|
+
() =>
|
|
41
|
+
({ width, src, quality }) => {
|
|
42
|
+
// Just emulate like we really resize the image
|
|
43
|
+
const params = new URLSearchParams();
|
|
44
|
+
params.set("width", `${width}`);
|
|
45
|
+
params.set("quality", `${quality}`);
|
|
46
|
+
return `${src}?${params.toString()}`;
|
|
47
|
+
};
|
|
@@ -1,155 +1,174 @@
|
|
|
1
1
|
import { getImageAttributes } from "./image-optimize";
|
|
2
2
|
import { cloudflareImageLoader } from "./image-loaders";
|
|
3
|
+
|
|
3
4
|
describe("Image optimizations applied", () => {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
test("width is number, create pixel density descriptor 'x'", () => {
|
|
6
|
+
const imgAttr = getImageAttributes({
|
|
7
|
+
optimize: true,
|
|
8
|
+
width: 100,
|
|
9
|
+
src: "https://webstudio.is/logo.webp",
|
|
10
|
+
srcSet: undefined,
|
|
11
|
+
sizes: undefined,
|
|
12
|
+
quality: 100,
|
|
13
|
+
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(imgAttr).toMatchInlineSnapshot(`
|
|
15
17
|
{
|
|
16
18
|
"sizes": undefined,
|
|
17
19
|
"src": "/cdn-cgi/image/width=256,quality=100,format=auto/https://webstudio.is/logo.webp",
|
|
18
20
|
"srcSet": "/cdn-cgi/image/width=128,quality=100,format=auto/https://webstudio.is/logo.webp 1x, /cdn-cgi/image/width=256,quality=100,format=auto/https://webstudio.is/logo.webp 2x",
|
|
19
21
|
}
|
|
20
22
|
`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("width is undefined, create 'w' descriptor and sizes prop", () => {
|
|
26
|
+
const imgAttr = getImageAttributes({
|
|
27
|
+
optimize: true,
|
|
28
|
+
width: undefined,
|
|
29
|
+
src: "https://webstudio.is/logo.webp",
|
|
30
|
+
srcSet: undefined,
|
|
31
|
+
sizes: undefined,
|
|
32
|
+
quality: 90,
|
|
33
|
+
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
21
34
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
optimize: true,
|
|
25
|
-
width: undefined,
|
|
26
|
-
src: "https://webstudio.is/logo.webp",
|
|
27
|
-
srcSet: undefined,
|
|
28
|
-
sizes: undefined,
|
|
29
|
-
quality: 90,
|
|
30
|
-
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
31
|
-
});
|
|
32
|
-
expect(imgAttr).toMatchInlineSnapshot(`
|
|
35
|
+
|
|
36
|
+
expect(imgAttr).toMatchInlineSnapshot(`
|
|
33
37
|
{
|
|
34
38
|
"sizes": "(min-width: 1280px) 50vw, 100vw",
|
|
35
39
|
"src": "/cdn-cgi/image/width=3840,quality=90,format=auto/https://webstudio.is/logo.webp",
|
|
36
40
|
"srcSet": "/cdn-cgi/image/width=384,quality=90,format=auto/https://webstudio.is/logo.webp 384w, /cdn-cgi/image/width=640,quality=90,format=auto/https://webstudio.is/logo.webp 640w, /cdn-cgi/image/width=750,quality=90,format=auto/https://webstudio.is/logo.webp 750w, /cdn-cgi/image/width=828,quality=90,format=auto/https://webstudio.is/logo.webp 828w, /cdn-cgi/image/width=1080,quality=90,format=auto/https://webstudio.is/logo.webp 1080w, /cdn-cgi/image/width=1200,quality=90,format=auto/https://webstudio.is/logo.webp 1200w, /cdn-cgi/image/width=1920,quality=90,format=auto/https://webstudio.is/logo.webp 1920w, /cdn-cgi/image/width=2048,quality=90,format=auto/https://webstudio.is/logo.webp 2048w, /cdn-cgi/image/width=3840,quality=90,format=auto/https://webstudio.is/logo.webp 3840w",
|
|
37
41
|
}
|
|
38
42
|
`);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("width is undefined and size defined, creates 'w' descriptor and use input sizes props", () => {
|
|
46
|
+
const imgAttr = getImageAttributes({
|
|
47
|
+
optimize: true,
|
|
48
|
+
width: undefined,
|
|
49
|
+
src: "https://webstudio.is/logo.webp",
|
|
50
|
+
srcSet: undefined,
|
|
51
|
+
sizes: "100vw",
|
|
52
|
+
quality: 70,
|
|
53
|
+
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
39
54
|
});
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
optimize: true,
|
|
43
|
-
width: undefined,
|
|
44
|
-
src: "https://webstudio.is/logo.webp",
|
|
45
|
-
srcSet: undefined,
|
|
46
|
-
sizes: "100vw",
|
|
47
|
-
quality: 70,
|
|
48
|
-
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
49
|
-
});
|
|
50
|
-
expect(imgAttr).toMatchInlineSnapshot(`
|
|
55
|
+
|
|
56
|
+
expect(imgAttr).toMatchInlineSnapshot(`
|
|
51
57
|
{
|
|
52
58
|
"sizes": "100vw",
|
|
53
59
|
"src": "/cdn-cgi/image/width=3840,quality=70,format=auto/https://webstudio.is/logo.webp",
|
|
54
60
|
"srcSet": "/cdn-cgi/image/width=640,quality=70,format=auto/https://webstudio.is/logo.webp 640w, /cdn-cgi/image/width=750,quality=70,format=auto/https://webstudio.is/logo.webp 750w, /cdn-cgi/image/width=828,quality=70,format=auto/https://webstudio.is/logo.webp 828w, /cdn-cgi/image/width=1080,quality=70,format=auto/https://webstudio.is/logo.webp 1080w, /cdn-cgi/image/width=1200,quality=70,format=auto/https://webstudio.is/logo.webp 1200w, /cdn-cgi/image/width=1920,quality=70,format=auto/https://webstudio.is/logo.webp 1920w, /cdn-cgi/image/width=2048,quality=70,format=auto/https://webstudio.is/logo.webp 2048w, /cdn-cgi/image/width=3840,quality=70,format=auto/https://webstudio.is/logo.webp 3840w",
|
|
55
61
|
}
|
|
56
62
|
`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("width is undefined and size defined, creates 'w' descriptor and use input sizes props, resizeOrigin defined", () => {
|
|
66
|
+
const imgAttr = getImageAttributes({
|
|
67
|
+
optimize: true,
|
|
68
|
+
width: undefined,
|
|
69
|
+
src: "https://webstudio.is/logo.webp",
|
|
70
|
+
srcSet: undefined,
|
|
71
|
+
sizes: "100vw",
|
|
72
|
+
quality: 70,
|
|
73
|
+
loader: cloudflareImageLoader({
|
|
74
|
+
resizeOrigin: "https://resize-origin.is",
|
|
75
|
+
}),
|
|
57
76
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
optimize: true,
|
|
61
|
-
width: undefined,
|
|
62
|
-
src: "https://webstudio.is/logo.webp",
|
|
63
|
-
srcSet: undefined,
|
|
64
|
-
sizes: "100vw",
|
|
65
|
-
quality: 70,
|
|
66
|
-
loader: cloudflareImageLoader({
|
|
67
|
-
resizeOrigin: "https://resize-origin.is",
|
|
68
|
-
}),
|
|
69
|
-
});
|
|
70
|
-
expect(imgAttr).toMatchInlineSnapshot(`
|
|
77
|
+
|
|
78
|
+
expect(imgAttr).toMatchInlineSnapshot(`
|
|
71
79
|
{
|
|
72
80
|
"sizes": "100vw",
|
|
73
81
|
"src": "https://resize-origin.is/cdn-cgi/image/width=3840,quality=70,format=auto/https://webstudio.is/logo.webp",
|
|
74
82
|
"srcSet": "https://resize-origin.is/cdn-cgi/image/width=640,quality=70,format=auto/https://webstudio.is/logo.webp 640w, https://resize-origin.is/cdn-cgi/image/width=750,quality=70,format=auto/https://webstudio.is/logo.webp 750w, https://resize-origin.is/cdn-cgi/image/width=828,quality=70,format=auto/https://webstudio.is/logo.webp 828w, https://resize-origin.is/cdn-cgi/image/width=1080,quality=70,format=auto/https://webstudio.is/logo.webp 1080w, https://resize-origin.is/cdn-cgi/image/width=1200,quality=70,format=auto/https://webstudio.is/logo.webp 1200w, https://resize-origin.is/cdn-cgi/image/width=1920,quality=70,format=auto/https://webstudio.is/logo.webp 1920w, https://resize-origin.is/cdn-cgi/image/width=2048,quality=70,format=auto/https://webstudio.is/logo.webp 2048w, https://resize-origin.is/cdn-cgi/image/width=3840,quality=70,format=auto/https://webstudio.is/logo.webp 3840w",
|
|
75
83
|
}
|
|
76
84
|
`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("custom loader", () => {
|
|
88
|
+
const imgAttr = getImageAttributes({
|
|
89
|
+
optimize: true,
|
|
90
|
+
width: undefined,
|
|
91
|
+
src: "https://webstudio.is/logo.webp",
|
|
92
|
+
srcSet: undefined,
|
|
93
|
+
sizes: "100vw",
|
|
94
|
+
quality: 70,
|
|
95
|
+
loader: ({ width, src, quality }) =>
|
|
96
|
+
`${new URL(src).pathname}?w=${width}&q=${quality}`,
|
|
77
97
|
});
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
optimize: true,
|
|
81
|
-
width: undefined,
|
|
82
|
-
src: "https://webstudio.is/logo.webp",
|
|
83
|
-
srcSet: undefined,
|
|
84
|
-
sizes: "100vw",
|
|
85
|
-
quality: 70,
|
|
86
|
-
loader: ({ width, src, quality }) => `${new URL(src).pathname}?w=${width}&q=${quality}`,
|
|
87
|
-
});
|
|
88
|
-
expect(imgAttr).toMatchInlineSnapshot(`
|
|
98
|
+
|
|
99
|
+
expect(imgAttr).toMatchInlineSnapshot(`
|
|
89
100
|
{
|
|
90
101
|
"sizes": "100vw",
|
|
91
102
|
"src": "/logo.webp?w=3840&q=70",
|
|
92
103
|
"srcSet": "/logo.webp?w=640&q=70 640w, /logo.webp?w=750&q=70 750w, /logo.webp?w=828&q=70 828w, /logo.webp?w=1080&q=70 1080w, /logo.webp?w=1200&q=70 1200w, /logo.webp?w=1920&q=70 1920w, /logo.webp?w=2048&q=70 2048w, /logo.webp?w=3840&q=70 3840w",
|
|
93
104
|
}
|
|
94
105
|
`);
|
|
95
|
-
|
|
106
|
+
});
|
|
96
107
|
});
|
|
108
|
+
|
|
97
109
|
describe("Image optimizations not applied", () => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
110
|
+
test("optimize is false", () => {
|
|
111
|
+
const imgAttr = getImageAttributes({
|
|
112
|
+
optimize: false,
|
|
113
|
+
width: 100,
|
|
114
|
+
src: "https://webstudio.is/logo.webp",
|
|
115
|
+
srcSet: undefined,
|
|
116
|
+
sizes: undefined,
|
|
117
|
+
quality: 100,
|
|
118
|
+
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(imgAttr).toMatchInlineSnapshot(`
|
|
109
122
|
{
|
|
110
123
|
"src": "https://webstudio.is/logo.webp",
|
|
111
124
|
}
|
|
112
125
|
`);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("srcSet is defined", () => {
|
|
129
|
+
const imgAttr = getImageAttributes({
|
|
130
|
+
optimize: true,
|
|
131
|
+
width: 100,
|
|
132
|
+
src: "https://webstudio.is/logo.webp",
|
|
133
|
+
srcSet: "user-defined-srcset",
|
|
134
|
+
sizes: undefined,
|
|
135
|
+
quality: 100,
|
|
136
|
+
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
113
137
|
});
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
optimize: true,
|
|
117
|
-
width: 100,
|
|
118
|
-
src: "https://webstudio.is/logo.webp",
|
|
119
|
-
srcSet: "user-defined-srcset",
|
|
120
|
-
sizes: undefined,
|
|
121
|
-
quality: 100,
|
|
122
|
-
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
123
|
-
});
|
|
124
|
-
expect(imgAttr).toMatchInlineSnapshot(`
|
|
138
|
+
|
|
139
|
+
expect(imgAttr).toMatchInlineSnapshot(`
|
|
125
140
|
{
|
|
126
141
|
"src": "https://webstudio.is/logo.webp",
|
|
127
142
|
"srcSet": "user-defined-srcset",
|
|
128
143
|
}
|
|
129
144
|
`);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("src is empty", () => {
|
|
148
|
+
const imgAttr = getImageAttributes({
|
|
149
|
+
optimize: true,
|
|
150
|
+
width: 100,
|
|
151
|
+
src: "",
|
|
152
|
+
srcSet: undefined,
|
|
153
|
+
sizes: undefined,
|
|
154
|
+
quality: 100,
|
|
155
|
+
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
130
156
|
});
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const imgAttr = getImageAttributes({
|
|
145
|
-
optimize: true,
|
|
146
|
-
width: 100,
|
|
147
|
-
src: undefined,
|
|
148
|
-
srcSet: undefined,
|
|
149
|
-
sizes: undefined,
|
|
150
|
-
quality: 100,
|
|
151
|
-
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
152
|
-
});
|
|
153
|
-
expect(imgAttr).toMatchInlineSnapshot(`null`);
|
|
157
|
+
|
|
158
|
+
expect(imgAttr).toMatchInlineSnapshot(`null`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("src is undefined", () => {
|
|
162
|
+
const imgAttr = getImageAttributes({
|
|
163
|
+
optimize: true,
|
|
164
|
+
width: 100,
|
|
165
|
+
src: undefined,
|
|
166
|
+
srcSet: undefined,
|
|
167
|
+
sizes: undefined,
|
|
168
|
+
quality: 100,
|
|
169
|
+
loader: cloudflareImageLoader({ resizeOrigin: null }),
|
|
154
170
|
});
|
|
171
|
+
|
|
172
|
+
expect(imgAttr).toMatchInlineSnapshot(`null`);
|
|
173
|
+
});
|
|
155
174
|
});
|
|
@@ -85,23 +85,176 @@
|
|
|
85
85
|
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
|
86
86
|
* ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
87
87
|
**/
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
|
|
89
|
+
export type ImageLoader = (props: {
|
|
90
|
+
width: number;
|
|
91
|
+
quality: number;
|
|
92
|
+
src: string;
|
|
92
93
|
}) => string;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* max(...imageSizes) must be less then min(...deviceSizes)
|
|
97
|
+
**/
|
|
98
|
+
const imageSizes = [16, 32, 48, 64, 96, 128, 256, 384];
|
|
99
|
+
const deviceSizes = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
|
|
100
|
+
|
|
101
|
+
export const allSizes = [...imageSizes, ...deviceSizes];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* https://github.com/vercel/next.js/blob/canary/packages/next/client/image.tsx
|
|
105
|
+
**/
|
|
106
|
+
const getWidths = (
|
|
107
|
+
width: number | undefined,
|
|
108
|
+
sizes: string | undefined
|
|
109
|
+
): { widths: number[]; kind: "w" | "x" } => {
|
|
110
|
+
if (sizes) {
|
|
111
|
+
// Find all the "vw" percent sizes used in the sizes prop
|
|
112
|
+
const viewportWidthRe = /(^|\s)(1?\d?\d)vw/g;
|
|
113
|
+
const percentSizes = [];
|
|
114
|
+
for (let match; (match = viewportWidthRe.exec(sizes)); match) {
|
|
115
|
+
percentSizes.push(parseInt(match[2], 10));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (percentSizes.length) {
|
|
119
|
+
// we can exclude from srcSets all images which are smaller than the smallestRatio * smallesDeviceSize
|
|
120
|
+
const smallestRatio = Math.min(...percentSizes) * 0.01;
|
|
121
|
+
return {
|
|
122
|
+
widths: allSizes.filter((s) => s >= deviceSizes[0] * smallestRatio),
|
|
123
|
+
kind: "w",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { widths: allSizes, kind: "w" };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (width == null) {
|
|
130
|
+
return { widths: deviceSizes, kind: "w" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const widths = [
|
|
134
|
+
...new Set(
|
|
135
|
+
[width, width * 2].map(
|
|
136
|
+
(w) => allSizes.find((p) => p >= w) || allSizes[allSizes.length - 1]
|
|
137
|
+
)
|
|
138
|
+
),
|
|
139
|
+
];
|
|
140
|
+
return { widths, kind: "x" };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const generateImgAttrs = ({
|
|
144
|
+
src,
|
|
145
|
+
width,
|
|
146
|
+
quality,
|
|
147
|
+
sizes,
|
|
148
|
+
loader,
|
|
149
|
+
}: {
|
|
150
|
+
src: string;
|
|
151
|
+
quality: number;
|
|
152
|
+
width: number | undefined;
|
|
153
|
+
sizes: string | undefined;
|
|
154
|
+
loader: ImageLoader;
|
|
155
|
+
}): {
|
|
156
|
+
src: string;
|
|
157
|
+
srcSet: string | undefined;
|
|
158
|
+
sizes: string | undefined;
|
|
159
|
+
} => {
|
|
160
|
+
const { widths, kind } = getWidths(width, sizes);
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
sizes: !sizes && kind === "w" ? "100vw" : sizes,
|
|
164
|
+
srcSet: widths
|
|
165
|
+
.map(
|
|
166
|
+
(w, i) =>
|
|
167
|
+
`${loader({ src, quality, width: w })} ${
|
|
168
|
+
kind === "w" ? w : i + 1
|
|
169
|
+
}${kind}`
|
|
170
|
+
)
|
|
171
|
+
.join(", "),
|
|
172
|
+
|
|
173
|
+
// Must be last, to prevent Safari to load images twice
|
|
174
|
+
src: loader({
|
|
175
|
+
src,
|
|
176
|
+
quality,
|
|
177
|
+
width: widths[widths.length - 1],
|
|
178
|
+
}),
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const getInt = (value: unknown): number | undefined => {
|
|
183
|
+
if (typeof value === "number") {
|
|
184
|
+
return Math.round(value);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (typeof value === "string") {
|
|
188
|
+
const vNum = Number.parseFloat(value);
|
|
189
|
+
|
|
190
|
+
if (!Number.isNaN(vNum)) {
|
|
191
|
+
return Math.round(vNum);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return undefined;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* DEFAULT_SIZES Just an assumption that most images (except hero and icons) are 100% wide on mobile and 50% on desktop.
|
|
200
|
+
* For icons width are usually set explicitly so DEFAULT_SIZES is not applied.
|
|
201
|
+
* For hero images, we can allow in UI to select sizes=100vw explicitly.
|
|
202
|
+
* Anyway, the best would be to calculate this based on canvas data from different breakpoints.
|
|
203
|
+
* See ../component-utils/image for detailed description
|
|
204
|
+
**/
|
|
205
|
+
const DEFAULT_SIZES = "(min-width: 1280px) 50vw, 100vw";
|
|
206
|
+
|
|
207
|
+
const DEFAULT_QUALITY = 80;
|
|
208
|
+
|
|
209
|
+
export const getImageAttributes = (props: {
|
|
210
|
+
src: string | undefined;
|
|
211
|
+
srcSet: string | undefined;
|
|
212
|
+
sizes: string | undefined;
|
|
213
|
+
width: string | number | undefined;
|
|
214
|
+
quality: string | number | undefined;
|
|
215
|
+
loader: ImageLoader;
|
|
216
|
+
optimize: boolean;
|
|
217
|
+
}): {
|
|
218
|
+
src: string;
|
|
219
|
+
srcSet?: string;
|
|
220
|
+
sizes?: string;
|
|
221
|
+
} | null => {
|
|
222
|
+
const width = getInt(props.width);
|
|
223
|
+
const quality = Math.max(
|
|
224
|
+
Math.min(getInt(props.quality) ?? DEFAULT_QUALITY, 100),
|
|
225
|
+
0
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
if (props.src != null && props.src !== "") {
|
|
229
|
+
if (props.srcSet == null && props.optimize) {
|
|
230
|
+
const sizes =
|
|
231
|
+
props.sizes ?? (props.width == null ? DEFAULT_SIZES : undefined);
|
|
232
|
+
|
|
233
|
+
return generateImgAttrs({
|
|
234
|
+
src: props.src,
|
|
235
|
+
width,
|
|
236
|
+
quality,
|
|
237
|
+
sizes,
|
|
238
|
+
loader: props.loader,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const resAttrs: {
|
|
243
|
+
src: string;
|
|
244
|
+
srcSet?: string;
|
|
245
|
+
sizes?: string;
|
|
246
|
+
} = { src: props.src };
|
|
247
|
+
|
|
248
|
+
if (props.srcSet != null) {
|
|
249
|
+
resAttrs.srcSet = props.srcSet;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (props.sizes != null) {
|
|
253
|
+
resAttrs.sizes = props.sizes;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return resAttrs;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return null;
|
|
260
|
+
};
|
package/src/image.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { forwardRef, type ElementRef, type ComponentProps } from "react";
|
|
2
|
+
import { getImageAttributes, type ImageLoader } from "./image-optimize";
|
|
3
|
+
|
|
4
|
+
const defaultTag = "img";
|
|
5
|
+
|
|
6
|
+
type ImageProps = ComponentProps<typeof defaultTag> & {
|
|
7
|
+
quality?: number;
|
|
8
|
+
optimize?: boolean;
|
|
9
|
+
loader: ImageLoader;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const Image = forwardRef<ElementRef<typeof defaultTag>, ImageProps>(
|
|
13
|
+
(
|
|
14
|
+
{
|
|
15
|
+
quality,
|
|
16
|
+
loader,
|
|
17
|
+
optimize = true,
|
|
18
|
+
loading = "lazy",
|
|
19
|
+
decoding = "async",
|
|
20
|
+
...imageProps
|
|
21
|
+
},
|
|
22
|
+
ref
|
|
23
|
+
) => {
|
|
24
|
+
const imageAttributes = getImageAttributes({
|
|
25
|
+
src: imageProps.src,
|
|
26
|
+
srcSet: imageProps.srcSet,
|
|
27
|
+
sizes: imageProps.sizes,
|
|
28
|
+
width: imageProps.width,
|
|
29
|
+
quality,
|
|
30
|
+
loader,
|
|
31
|
+
optimize,
|
|
32
|
+
}) ?? { src: imagePlaceholderSvg };
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<img
|
|
36
|
+
{...imageProps}
|
|
37
|
+
{...imageAttributes}
|
|
38
|
+
decoding={decoding}
|
|
39
|
+
loading={loading}
|
|
40
|
+
ref={ref}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
Image.defaultProps = {
|
|
47
|
+
src: "",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
Image.displayName = "Image";
|
|
51
|
+
|
|
52
|
+
const imagePlaceholderSvg = `data:image/svg+xml;base64,${btoa(`<svg
|
|
53
|
+
width="140"
|
|
54
|
+
height="140"
|
|
55
|
+
viewBox="0 0 600 600"
|
|
56
|
+
fill="none"
|
|
57
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
58
|
+
>
|
|
59
|
+
<rect width="600" height="600" fill="#CCCCCC" />
|
|
60
|
+
<path
|
|
61
|
+
fill-rule="evenodd"
|
|
62
|
+
clip-rule="evenodd"
|
|
63
|
+
d="M450 170H150C141.716 170 135 176.716 135 185V415C135 423.284 141.716 430 150 430H450C458.284 430 465 423.284 465 415V185C465 176.716 458.284 170 450 170ZM150 145C127.909 145 110 162.909 110 185V415C110 437.091 127.909 455 150 455H450C472.091 455 490 437.091 490 415V185C490 162.909 472.091 145 450 145H150Z"
|
|
64
|
+
fill="#A2A2A2"
|
|
65
|
+
/>
|
|
66
|
+
<path
|
|
67
|
+
d="M237.135 235.012C237.135 255.723 220.345 272.512 199.635 272.512C178.924 272.512 162.135 255.723 162.135 235.012C162.135 214.301 178.924 197.512 199.635 197.512C220.345 197.512 237.135 214.301 237.135 235.012Z"
|
|
68
|
+
fill="#A2A2A2"
|
|
69
|
+
/>
|
|
70
|
+
<path
|
|
71
|
+
d="M160 405V367.205L221.609 306.364L256.552 338.628L358.161 234L440 316.043V405H160Z"
|
|
72
|
+
fill="#A2A2A2"
|
|
73
|
+
/>
|
|
74
|
+
</svg>`)}`;
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
-
};
|
|
5
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.HeroImage = exports.FillParentImage = exports.AspectRatioImage = exports.UnknownWidthImage = exports.FixedWidthImageCover = exports.FixedWidthImage = void 0;
|
|
7
|
-
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
|
-
const _1 = require("./");
|
|
9
|
-
// to not allow include local assets everywhere, just enable it for this file
|
|
10
|
-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
11
|
-
// @ts-ignore
|
|
12
|
-
const logo_webp_1 = __importDefault(require("../storybook-assets/logo.webp"));
|
|
13
|
-
exports.default = {
|
|
14
|
-
title: "Components/ImageDev",
|
|
15
|
-
};
|
|
16
|
-
/**
|
|
17
|
-
* In case you need to test img with real cloudflare trasforms
|
|
18
|
-
* set USE_CLOUDFLARE_IMAGE_TRANSFORM = true
|
|
19
|
-
**/
|
|
20
|
-
const USE_CLOUDFLARE_IMAGE_TRANSFORM = false;
|
|
21
|
-
// For cloudflare image transform testing, logo should be the most consistent image on the site
|
|
22
|
-
const REMOTE_SELF_DOMAIN_IMAGE = "https://webstudio.is/logo.webp";
|
|
23
|
-
const imageSrc = USE_CLOUDFLARE_IMAGE_TRANSFORM
|
|
24
|
-
? REMOTE_SELF_DOMAIN_IMAGE
|
|
25
|
-
: logo_webp_1.default;
|
|
26
|
-
const imageLoader = USE_CLOUDFLARE_IMAGE_TRANSFORM
|
|
27
|
-
? _1.loaders.cloudflareImageLoader({ resizeOrigin: "https://webstudio.is" })
|
|
28
|
-
: _1.loaders.localImageLoader();
|
|
29
|
-
const ImageBase = (args) => {
|
|
30
|
-
const style = {
|
|
31
|
-
maxWidth: "100%",
|
|
32
|
-
display: "block",
|
|
33
|
-
...args.style,
|
|
34
|
-
};
|
|
35
|
-
return ((0, jsx_runtime_1.jsx)(_1.Image, { ...args, optimize: true, loader: imageLoader, style: style }));
|
|
36
|
-
};
|
|
37
|
-
/**
|
|
38
|
-
* Load images depending on image width and device per pixel ratio.
|
|
39
|
-
**/
|
|
40
|
-
const FixedWidthImage = () => ((0, jsx_runtime_1.jsx)(ImageBase, { src: imageSrc, width: "300", height: "400" }));
|
|
41
|
-
exports.FixedWidthImage = FixedWidthImage;
|
|
42
|
-
/**
|
|
43
|
-
* Preserve ratio using object-fit: cover. Load images depending on image width and device per pixel ratio.
|
|
44
|
-
**/
|
|
45
|
-
const FixedWidthImageCover = () => ((0, jsx_runtime_1.jsx)(ImageBase, { src: imageSrc, width: "300", height: "400", style: { objectFit: "cover" } }));
|
|
46
|
-
exports.FixedWidthImageCover = FixedWidthImageCover;
|
|
47
|
-
/**
|
|
48
|
-
* Load images depending on the viewport width.
|
|
49
|
-
**/
|
|
50
|
-
const UnknownWidthImage = () => (0, jsx_runtime_1.jsx)(ImageBase, { src: imageSrc });
|
|
51
|
-
exports.UnknownWidthImage = UnknownWidthImage;
|
|
52
|
-
/**
|
|
53
|
-
* Fit width of the parent container, has own aspect-ratio and object-fit=cover.
|
|
54
|
-
* Load images depending on the viewport width.
|
|
55
|
-
**/
|
|
56
|
-
const AspectRatioImage = () => ((0, jsx_runtime_1.jsx)("div", { style: { width: "50%" }, children: (0, jsx_runtime_1.jsx)(ImageBase, { src: imageSrc, style: { aspectRatio: "2/1", objectFit: "cover", width: "100%" } }) }));
|
|
57
|
-
exports.AspectRatioImage = AspectRatioImage;
|
|
58
|
-
/**
|
|
59
|
-
* Fill width and height of the relative parent container, object-fit=cover. Load images depending on the viewport width.
|
|
60
|
-
**/
|
|
61
|
-
const FillParentImage = () => ((0, jsx_runtime_1.jsx)("div", { style: { width: "50%", aspectRatio: "2/1", position: "relative" }, children: (0, jsx_runtime_1.jsx)(ImageBase, { src: imageSrc, style: {
|
|
62
|
-
objectFit: "cover",
|
|
63
|
-
position: "absolute",
|
|
64
|
-
width: "100%",
|
|
65
|
-
height: "100%",
|
|
66
|
-
} }) }));
|
|
67
|
-
exports.FillParentImage = FillParentImage;
|
|
68
|
-
/**
|
|
69
|
-
* "sizes" attribute explicitly equal to 100vw allowing to skip the default behavior.
|
|
70
|
-
* See DEFAULT_SIZES in the Image component. Load images depending on the viewport width.
|
|
71
|
-
**/
|
|
72
|
-
const HeroImage = () => ((0, jsx_runtime_1.jsx)(ImageBase, { src: imageSrc, sizes: "100vw", style: {
|
|
73
|
-
aspectRatio: "3/1",
|
|
74
|
-
objectFit: "cover",
|
|
75
|
-
width: "100%",
|
|
76
|
-
} }));
|
|
77
|
-
exports.HeroImage = HeroImage;
|