@wordpress/media-fields 0.5.2-next.v.202602241322.0 → 0.6.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/build/media_thumbnail/view.cjs +92 -13
- package/build/media_thumbnail/view.cjs.map +3 -3
- package/build-module/media_thumbnail/view.mjs +79 -14
- package/build-module/media_thumbnail/view.mjs.map +2 -2
- package/build-style/style-rtl.css +11 -0
- package/build-style/style.css +11 -0
- package/build-types/media_thumbnail/test/get-best-image-url.test.d.ts +2 -0
- package/build-types/media_thumbnail/test/get-best-image-url.test.d.ts.map +1 -0
- package/build-types/media_thumbnail/view.d.ts +10 -0
- package/build-types/media_thumbnail/view.d.ts.map +1 -1
- package/package.json +14 -14
- package/src/media_thumbnail/style.scss +21 -0
- package/src/media_thumbnail/test/get-best-image-url.test.ts +161 -0
- package/src/media_thumbnail/view.tsx +122 -27
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
2
3
|
var __defProp = Object.defineProperty;
|
|
3
4
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
5
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
5
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
8
|
var __export = (target, all) => {
|
|
7
9
|
for (var name in all)
|
|
@@ -15,14 +17,24 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
15
17
|
}
|
|
16
18
|
return to;
|
|
17
19
|
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
18
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
29
|
|
|
20
30
|
// packages/media-fields/src/media_thumbnail/view.tsx
|
|
21
31
|
var view_exports = {};
|
|
22
32
|
__export(view_exports, {
|
|
23
|
-
default: () => MediaThumbnailView
|
|
33
|
+
default: () => MediaThumbnailView,
|
|
34
|
+
getBestImageUrl: () => getBestImageUrl
|
|
24
35
|
});
|
|
25
36
|
module.exports = __toCommonJS(view_exports);
|
|
37
|
+
var import_clsx = __toESM(require("clsx"));
|
|
26
38
|
var import_data = require("@wordpress/data");
|
|
27
39
|
var import_core_data = require("@wordpress/core-data");
|
|
28
40
|
var import_components = require("@wordpress/components");
|
|
@@ -30,6 +42,34 @@ var import_element = require("@wordpress/element");
|
|
|
30
42
|
var import_url = require("@wordpress/url");
|
|
31
43
|
var import_get_media_type_from_mime_type = require("../utils/get-media-type-from-mime-type.cjs");
|
|
32
44
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
45
|
+
function getBestImageUrl(featuredMedia, configSizes) {
|
|
46
|
+
const sizes = featuredMedia?.media_details?.sizes;
|
|
47
|
+
if (!sizes) {
|
|
48
|
+
return featuredMedia.source_url;
|
|
49
|
+
}
|
|
50
|
+
const sizeEntries = Object.values(sizes);
|
|
51
|
+
if (!sizeEntries.length) {
|
|
52
|
+
return featuredMedia.source_url;
|
|
53
|
+
}
|
|
54
|
+
const targetWidth = configSizes ? parseInt(configSizes, 10) : NaN;
|
|
55
|
+
if (!Number.isNaN(targetWidth)) {
|
|
56
|
+
const validEntries = sizeEntries.filter(
|
|
57
|
+
(s) => typeof s.width === "number" && !Number.isNaN(s.width)
|
|
58
|
+
);
|
|
59
|
+
if (!validEntries.length) {
|
|
60
|
+
return featuredMedia.source_url;
|
|
61
|
+
}
|
|
62
|
+
const sorted = [...validEntries].sort(
|
|
63
|
+
(a, b) => a.width - b.width
|
|
64
|
+
);
|
|
65
|
+
const match = sorted.find((s) => s.width >= targetWidth);
|
|
66
|
+
if (match) {
|
|
67
|
+
return match.source_url;
|
|
68
|
+
}
|
|
69
|
+
return sorted[sorted.length - 1].source_url;
|
|
70
|
+
}
|
|
71
|
+
return featuredMedia.source_url;
|
|
72
|
+
}
|
|
33
73
|
function FallbackView({
|
|
34
74
|
item,
|
|
35
75
|
filename
|
|
@@ -55,6 +95,48 @@ function FallbackView({
|
|
|
55
95
|
}
|
|
56
96
|
) });
|
|
57
97
|
}
|
|
98
|
+
function ImageView({
|
|
99
|
+
item,
|
|
100
|
+
configSizes,
|
|
101
|
+
onError
|
|
102
|
+
}) {
|
|
103
|
+
const imageUrl = getBestImageUrl(item, configSizes);
|
|
104
|
+
const imgRef = (0, import_element.useRef)(null);
|
|
105
|
+
const [loadingState, setLoadingState] = (0, import_element.useState)("loading");
|
|
106
|
+
(0, import_element.useLayoutEffect)(() => {
|
|
107
|
+
if (imgRef.current?.complete) {
|
|
108
|
+
setLoadingState("instant");
|
|
109
|
+
} else {
|
|
110
|
+
setLoadingState("loading");
|
|
111
|
+
}
|
|
112
|
+
}, [imageUrl]);
|
|
113
|
+
const handleLoad = () => {
|
|
114
|
+
if (loadingState === "loading") {
|
|
115
|
+
setLoadingState("loaded");
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
119
|
+
"div",
|
|
120
|
+
{
|
|
121
|
+
className: (0, import_clsx.default)("dataviews-media-field__media-thumbnail", {
|
|
122
|
+
"is-loading": loadingState === "loading",
|
|
123
|
+
"is-loaded": loadingState === "loaded"
|
|
124
|
+
}),
|
|
125
|
+
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
126
|
+
"img",
|
|
127
|
+
{
|
|
128
|
+
ref: imgRef,
|
|
129
|
+
className: "dataviews-media-field__media-thumbnail--image",
|
|
130
|
+
src: imageUrl,
|
|
131
|
+
alt: item.alt_text || item.title.raw,
|
|
132
|
+
onLoad: handleLoad,
|
|
133
|
+
onError,
|
|
134
|
+
loading: "lazy"
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
}
|
|
58
140
|
function MediaThumbnailView({
|
|
59
141
|
item,
|
|
60
142
|
config
|
|
@@ -81,20 +163,17 @@ function MediaThumbnailView({
|
|
|
81
163
|
if (imageError || (0, import_get_media_type_from_mime_type.getMediaTypeFromMimeType)(featuredMedia.mime_type).type !== "image") {
|
|
82
164
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FallbackView, { item: featuredMedia, filename: filename || "" });
|
|
83
165
|
}
|
|
84
|
-
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
85
|
-
|
|
166
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
167
|
+
ImageView,
|
|
86
168
|
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
srcSet: featuredMedia?.media_details?.sizes ? Object.values(
|
|
90
|
-
featuredMedia.media_details.sizes
|
|
91
|
-
).map(
|
|
92
|
-
(size) => `${size.source_url} ${size.width}w`
|
|
93
|
-
).join(", ") : void 0,
|
|
94
|
-
sizes: config?.sizes || "100vw",
|
|
95
|
-
alt: featuredMedia.alt_text || featuredMedia.title.raw,
|
|
169
|
+
item: featuredMedia,
|
|
170
|
+
configSizes: config?.sizes,
|
|
96
171
|
onError: () => setImageError(true)
|
|
97
172
|
}
|
|
98
|
-
)
|
|
173
|
+
);
|
|
99
174
|
}
|
|
175
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
176
|
+
0 && (module.exports = {
|
|
177
|
+
getBestImageUrl
|
|
178
|
+
});
|
|
100
179
|
//# sourceMappingURL=view.cjs.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/media_thumbnail/view.tsx"],
|
|
4
|
-
"sourcesContent": ["/**\n * WordPress dependencies\n */\nimport { useSelect } from '@wordpress/data';\nimport { store as coreStore } from '@wordpress/core-data';\nimport {\n\t__experimentalTruncate as Truncate,\n\t__experimentalVStack as VStack,\n\tIcon,\n} from '@wordpress/components';\nimport { useState } from '@wordpress/element';\nimport type { Attachment } from '@wordpress/core-data';\nimport { getFilename } from '@wordpress/url';\nimport type { DataViewRenderFieldProps } from '@wordpress/dataviews';\n/**\n * Internal dependencies\n */\nimport { getMediaTypeFromMimeType } from '../utils/get-media-type-from-mime-type';\nimport type { MediaItem } from '../types';\n\nfunction FallbackView( {\n\titem,\n\tfilename,\n}: {\n\titem: MediaItem;\n\tfilename: string;\n} ) {\n\treturn (\n\t\t<div className=\"dataviews-media-field__media-thumbnail\">\n\t\t\t<VStack\n\t\t\t\tjustify=\"center\"\n\t\t\t\talignment=\"center\"\n\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail__stack\"\n\t\t\t\tspacing={ 0 }\n\t\t\t>\n\t\t\t\t<Icon\n\t\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail--icon\"\n\t\t\t\t\ticon={ getMediaTypeFromMimeType( item.mime_type ).icon }\n\t\t\t\t\tsize={ 24 }\n\t\t\t\t/>\n\t\t\t\t{ !! filename && (\n\t\t\t\t\t<div className=\"dataviews-media-field__media-thumbnail__filename\">\n\t\t\t\t\t\t<Truncate className=\"dataviews-media-field__media-thumbnail__filename__truncate\">\n\t\t\t\t\t\t\t{ filename }\n\t\t\t\t\t\t</Truncate>\n\t\t\t\t\t</div>\n\t\t\t\t) }\n\t\t\t</VStack>\n\t\t</div>\n\t);\n}\n\
|
|
5
|
-
"mappings": "
|
|
6
|
-
"names": ["VStack", "Truncate", "coreStore"]
|
|
4
|
+
"sourcesContent": ["/**\n * External dependencies\n */\nimport clsx from 'clsx';\n\n/**\n * WordPress dependencies\n */\nimport { useSelect } from '@wordpress/data';\nimport { store as coreStore } from '@wordpress/core-data';\nimport {\n\t__experimentalTruncate as Truncate,\n\t__experimentalVStack as VStack,\n\tIcon,\n} from '@wordpress/components';\nimport { useState, useRef, useLayoutEffect } from '@wordpress/element';\nimport type { Attachment } from '@wordpress/core-data';\nimport { getFilename } from '@wordpress/url';\nimport type { DataViewRenderFieldProps } from '@wordpress/dataviews';\n/**\n * Internal dependencies\n */\nimport { getMediaTypeFromMimeType } from '../utils/get-media-type-from-mime-type';\nimport type { MediaItem } from '../types';\n\n/**\n * Given the available image sizes and a target display width, returns the URL\n * of the smallest size whose width is >= the target. Falls back to the largest\n * available size, or the original source_url.\n *\n * @param featuredMedia The media item with size details.\n * @param configSizes The target display size string (e.g. '900px').\n */\nexport function getBestImageUrl(\n\tfeaturedMedia: Attachment | MediaItem,\n\tconfigSizes?: string\n): string {\n\tconst sizes = featuredMedia?.media_details?.sizes;\n\tif ( ! sizes ) {\n\t\treturn featuredMedia.source_url;\n\t}\n\n\tconst sizeEntries = Object.values( sizes );\n\n\tif ( ! sizeEntries.length ) {\n\t\treturn featuredMedia.source_url;\n\t}\n\n\t// Parse target width from config.sizes (e.g. '900px' \u2192 900).\n\tconst targetWidth = configSizes ? parseInt( configSizes, 10 ) : NaN;\n\n\tif ( ! Number.isNaN( targetWidth ) ) {\n\t\t// Filter to entries that have a valid numeric width.\n\t\tconst validEntries = sizeEntries.filter(\n\t\t\t( s ) => typeof s.width === 'number' && ! Number.isNaN( s.width )\n\t\t);\n\n\t\tif ( ! validEntries.length ) {\n\t\t\treturn featuredMedia.source_url;\n\t\t}\n\n\t\t// Sort ascending by width.\n\t\tconst sorted = [ ...validEntries ].sort(\n\t\t\t( a, b ) => a.width - b.width\n\t\t);\n\t\t// Pick the smallest size that is >= target width.\n\t\tconst match = sorted.find( ( s ) => s.width >= targetWidth );\n\t\tif ( match ) {\n\t\t\treturn match.source_url;\n\t\t}\n\t\t// No size large enough \u2014 use the largest available.\n\t\treturn sorted[ sorted.length - 1 ].source_url;\n\t}\n\n\t// If we can't parse the target, fall back to source_url.\n\treturn featuredMedia.source_url;\n}\n\nfunction FallbackView( {\n\titem,\n\tfilename,\n}: {\n\titem: MediaItem;\n\tfilename: string;\n} ) {\n\treturn (\n\t\t<div className=\"dataviews-media-field__media-thumbnail\">\n\t\t\t<VStack\n\t\t\t\tjustify=\"center\"\n\t\t\t\talignment=\"center\"\n\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail__stack\"\n\t\t\t\tspacing={ 0 }\n\t\t\t>\n\t\t\t\t<Icon\n\t\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail--icon\"\n\t\t\t\t\ticon={ getMediaTypeFromMimeType( item.mime_type ).icon }\n\t\t\t\t\tsize={ 24 }\n\t\t\t\t/>\n\t\t\t\t{ !! filename && (\n\t\t\t\t\t<div className=\"dataviews-media-field__media-thumbnail__filename\">\n\t\t\t\t\t\t<Truncate className=\"dataviews-media-field__media-thumbnail__filename__truncate\">\n\t\t\t\t\t\t\t{ filename }\n\t\t\t\t\t\t</Truncate>\n\t\t\t\t\t</div>\n\t\t\t\t) }\n\t\t\t</VStack>\n\t\t</div>\n\t);\n}\n\nfunction ImageView( {\n\titem,\n\tconfigSizes,\n\tonError,\n}: {\n\titem: Attachment | MediaItem;\n\tconfigSizes?: string;\n\tonError: () => void;\n} ) {\n\tconst imageUrl = getBestImageUrl( item, configSizes );\n\n\t/*\n\t * Use three states to avoid fade-in animation for cached images:\n\t * 'instant' = image already cached, 'loading' = waiting, 'loaded' = just finished.\n\t *\n\t * useLayoutEffect runs synchronously after DOM mutations but before paint,\n\t * so we can check img.complete to detect disk-cached images and skip the\n\t * fade-in animation entirely.\n\t */\n\tconst imgRef = useRef< HTMLImageElement >( null );\n\tconst [ loadingState, setLoadingState ] = useState<\n\t\t'instant' | 'loading' | 'loaded'\n\t>( 'loading' );\n\n\tuseLayoutEffect( () => {\n\t\tif ( imgRef.current?.complete ) {\n\t\t\tsetLoadingState( 'instant' );\n\t\t} else {\n\t\t\tsetLoadingState( 'loading' );\n\t\t}\n\t}, [ imageUrl ] );\n\n\tconst handleLoad = () => {\n\t\tif ( loadingState === 'loading' ) {\n\t\t\tsetLoadingState( 'loaded' );\n\t\t}\n\t};\n\n\treturn (\n\t\t<div\n\t\t\tclassName={ clsx( 'dataviews-media-field__media-thumbnail', {\n\t\t\t\t'is-loading': loadingState === 'loading',\n\t\t\t\t'is-loaded': loadingState === 'loaded',\n\t\t\t} ) }\n\t\t>\n\t\t\t<img\n\t\t\t\tref={ imgRef }\n\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail--image\"\n\t\t\t\tsrc={ imageUrl }\n\t\t\t\talt={ item.alt_text || item.title.raw }\n\t\t\t\tonLoad={ handleLoad }\n\t\t\t\tonError={ onError }\n\t\t\t\tloading=\"lazy\"\n\t\t\t/>\n\t\t</div>\n\t);\n}\n\nexport default function MediaThumbnailView( {\n\titem,\n\tconfig,\n}: DataViewRenderFieldProps< MediaItem > ) {\n\tconst [ imageError, setImageError ] = useState( false );\n\n\tconst _featuredMedia = useSelect(\n\t\t( select ) => {\n\t\t\t// Avoid the network request if it's not needed. `featured_media` is\n\t\t\t// 0 for images and media without featured media.\n\t\t\tif ( ! item.featured_media ) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\treturn select( coreStore ).getEntityRecord< Attachment >(\n\t\t\t\t'postType',\n\t\t\t\t'attachment',\n\t\t\t\titem.featured_media\n\t\t\t);\n\t\t},\n\t\t[ item.featured_media ]\n\t);\n\tconst featuredMedia = item.featured_media ? _featuredMedia : item;\n\n\t// Fetching.\n\tif ( ! featuredMedia ) {\n\t\treturn null;\n\t}\n\n\tconst filename = getFilename( featuredMedia.source_url || '' );\n\n\t// Show fallback if image failed to load or if not an image type.\n\tif (\n\t\timageError ||\n\t\tgetMediaTypeFromMimeType( featuredMedia.mime_type ).type !== 'image'\n\t) {\n\t\treturn (\n\t\t\t<FallbackView item={ featuredMedia } filename={ filename || '' } />\n\t\t);\n\t}\n\n\treturn (\n\t\t<ImageView\n\t\t\titem={ featuredMedia }\n\t\t\tconfigSizes={ config?.sizes }\n\t\t\tonError={ () => setImageError( true ) }\n\t\t/>\n\t);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["VStack", "Truncate", "clsx", "coreStore"]
|
|
7
7
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// packages/media-fields/src/media_thumbnail/view.tsx
|
|
2
|
+
import clsx from "clsx";
|
|
2
3
|
import { useSelect } from "@wordpress/data";
|
|
3
4
|
import { store as coreStore } from "@wordpress/core-data";
|
|
4
5
|
import {
|
|
@@ -6,10 +7,38 @@ import {
|
|
|
6
7
|
__experimentalVStack as VStack,
|
|
7
8
|
Icon
|
|
8
9
|
} from "@wordpress/components";
|
|
9
|
-
import { useState } from "@wordpress/element";
|
|
10
|
+
import { useState, useRef, useLayoutEffect } from "@wordpress/element";
|
|
10
11
|
import { getFilename } from "@wordpress/url";
|
|
11
12
|
import { getMediaTypeFromMimeType } from "../utils/get-media-type-from-mime-type.mjs";
|
|
12
13
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
function getBestImageUrl(featuredMedia, configSizes) {
|
|
15
|
+
const sizes = featuredMedia?.media_details?.sizes;
|
|
16
|
+
if (!sizes) {
|
|
17
|
+
return featuredMedia.source_url;
|
|
18
|
+
}
|
|
19
|
+
const sizeEntries = Object.values(sizes);
|
|
20
|
+
if (!sizeEntries.length) {
|
|
21
|
+
return featuredMedia.source_url;
|
|
22
|
+
}
|
|
23
|
+
const targetWidth = configSizes ? parseInt(configSizes, 10) : NaN;
|
|
24
|
+
if (!Number.isNaN(targetWidth)) {
|
|
25
|
+
const validEntries = sizeEntries.filter(
|
|
26
|
+
(s) => typeof s.width === "number" && !Number.isNaN(s.width)
|
|
27
|
+
);
|
|
28
|
+
if (!validEntries.length) {
|
|
29
|
+
return featuredMedia.source_url;
|
|
30
|
+
}
|
|
31
|
+
const sorted = [...validEntries].sort(
|
|
32
|
+
(a, b) => a.width - b.width
|
|
33
|
+
);
|
|
34
|
+
const match = sorted.find((s) => s.width >= targetWidth);
|
|
35
|
+
if (match) {
|
|
36
|
+
return match.source_url;
|
|
37
|
+
}
|
|
38
|
+
return sorted[sorted.length - 1].source_url;
|
|
39
|
+
}
|
|
40
|
+
return featuredMedia.source_url;
|
|
41
|
+
}
|
|
13
42
|
function FallbackView({
|
|
14
43
|
item,
|
|
15
44
|
filename
|
|
@@ -35,6 +64,48 @@ function FallbackView({
|
|
|
35
64
|
}
|
|
36
65
|
) });
|
|
37
66
|
}
|
|
67
|
+
function ImageView({
|
|
68
|
+
item,
|
|
69
|
+
configSizes,
|
|
70
|
+
onError
|
|
71
|
+
}) {
|
|
72
|
+
const imageUrl = getBestImageUrl(item, configSizes);
|
|
73
|
+
const imgRef = useRef(null);
|
|
74
|
+
const [loadingState, setLoadingState] = useState("loading");
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
if (imgRef.current?.complete) {
|
|
77
|
+
setLoadingState("instant");
|
|
78
|
+
} else {
|
|
79
|
+
setLoadingState("loading");
|
|
80
|
+
}
|
|
81
|
+
}, [imageUrl]);
|
|
82
|
+
const handleLoad = () => {
|
|
83
|
+
if (loadingState === "loading") {
|
|
84
|
+
setLoadingState("loaded");
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
return /* @__PURE__ */ jsx(
|
|
88
|
+
"div",
|
|
89
|
+
{
|
|
90
|
+
className: clsx("dataviews-media-field__media-thumbnail", {
|
|
91
|
+
"is-loading": loadingState === "loading",
|
|
92
|
+
"is-loaded": loadingState === "loaded"
|
|
93
|
+
}),
|
|
94
|
+
children: /* @__PURE__ */ jsx(
|
|
95
|
+
"img",
|
|
96
|
+
{
|
|
97
|
+
ref: imgRef,
|
|
98
|
+
className: "dataviews-media-field__media-thumbnail--image",
|
|
99
|
+
src: imageUrl,
|
|
100
|
+
alt: item.alt_text || item.title.raw,
|
|
101
|
+
onLoad: handleLoad,
|
|
102
|
+
onError,
|
|
103
|
+
loading: "lazy"
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
);
|
|
108
|
+
}
|
|
38
109
|
function MediaThumbnailView({
|
|
39
110
|
item,
|
|
40
111
|
config
|
|
@@ -61,23 +132,17 @@ function MediaThumbnailView({
|
|
|
61
132
|
if (imageError || getMediaTypeFromMimeType(featuredMedia.mime_type).type !== "image") {
|
|
62
133
|
return /* @__PURE__ */ jsx(FallbackView, { item: featuredMedia, filename: filename || "" });
|
|
63
134
|
}
|
|
64
|
-
return /* @__PURE__ */ jsx(
|
|
65
|
-
|
|
135
|
+
return /* @__PURE__ */ jsx(
|
|
136
|
+
ImageView,
|
|
66
137
|
{
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
srcSet: featuredMedia?.media_details?.sizes ? Object.values(
|
|
70
|
-
featuredMedia.media_details.sizes
|
|
71
|
-
).map(
|
|
72
|
-
(size) => `${size.source_url} ${size.width}w`
|
|
73
|
-
).join(", ") : void 0,
|
|
74
|
-
sizes: config?.sizes || "100vw",
|
|
75
|
-
alt: featuredMedia.alt_text || featuredMedia.title.raw,
|
|
138
|
+
item: featuredMedia,
|
|
139
|
+
configSizes: config?.sizes,
|
|
76
140
|
onError: () => setImageError(true)
|
|
77
141
|
}
|
|
78
|
-
)
|
|
142
|
+
);
|
|
79
143
|
}
|
|
80
144
|
export {
|
|
81
|
-
MediaThumbnailView as default
|
|
145
|
+
MediaThumbnailView as default,
|
|
146
|
+
getBestImageUrl
|
|
82
147
|
};
|
|
83
148
|
//# sourceMappingURL=view.mjs.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/media_thumbnail/view.tsx"],
|
|
4
|
-
"sourcesContent": ["/**\n * WordPress dependencies\n */\nimport { useSelect } from '@wordpress/data';\nimport { store as coreStore } from '@wordpress/core-data';\nimport {\n\t__experimentalTruncate as Truncate,\n\t__experimentalVStack as VStack,\n\tIcon,\n} from '@wordpress/components';\nimport { useState } from '@wordpress/element';\nimport type { Attachment } from '@wordpress/core-data';\nimport { getFilename } from '@wordpress/url';\nimport type { DataViewRenderFieldProps } from '@wordpress/dataviews';\n/**\n * Internal dependencies\n */\nimport { getMediaTypeFromMimeType } from '../utils/get-media-type-from-mime-type';\nimport type { MediaItem } from '../types';\n\nfunction FallbackView( {\n\titem,\n\tfilename,\n}: {\n\titem: MediaItem;\n\tfilename: string;\n} ) {\n\treturn (\n\t\t<div className=\"dataviews-media-field__media-thumbnail\">\n\t\t\t<VStack\n\t\t\t\tjustify=\"center\"\n\t\t\t\talignment=\"center\"\n\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail__stack\"\n\t\t\t\tspacing={ 0 }\n\t\t\t>\n\t\t\t\t<Icon\n\t\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail--icon\"\n\t\t\t\t\ticon={ getMediaTypeFromMimeType( item.mime_type ).icon }\n\t\t\t\t\tsize={ 24 }\n\t\t\t\t/>\n\t\t\t\t{ !! filename && (\n\t\t\t\t\t<div className=\"dataviews-media-field__media-thumbnail__filename\">\n\t\t\t\t\t\t<Truncate className=\"dataviews-media-field__media-thumbnail__filename__truncate\">\n\t\t\t\t\t\t\t{ filename }\n\t\t\t\t\t\t</Truncate>\n\t\t\t\t\t</div>\n\t\t\t\t) }\n\t\t\t</VStack>\n\t\t</div>\n\t);\n}\n\
|
|
5
|
-
"mappings": ";AAGA,SAAS,iBAAiB;AAC1B,SAAS,SAAS,iBAAiB;AACnC;AAAA,EACC,0BAA0B;AAAA,EAC1B,wBAAwB;AAAA,EACxB;AAAA,OACM;AACP,SAAS,
|
|
4
|
+
"sourcesContent": ["/**\n * External dependencies\n */\nimport clsx from 'clsx';\n\n/**\n * WordPress dependencies\n */\nimport { useSelect } from '@wordpress/data';\nimport { store as coreStore } from '@wordpress/core-data';\nimport {\n\t__experimentalTruncate as Truncate,\n\t__experimentalVStack as VStack,\n\tIcon,\n} from '@wordpress/components';\nimport { useState, useRef, useLayoutEffect } from '@wordpress/element';\nimport type { Attachment } from '@wordpress/core-data';\nimport { getFilename } from '@wordpress/url';\nimport type { DataViewRenderFieldProps } from '@wordpress/dataviews';\n/**\n * Internal dependencies\n */\nimport { getMediaTypeFromMimeType } from '../utils/get-media-type-from-mime-type';\nimport type { MediaItem } from '../types';\n\n/**\n * Given the available image sizes and a target display width, returns the URL\n * of the smallest size whose width is >= the target. Falls back to the largest\n * available size, or the original source_url.\n *\n * @param featuredMedia The media item with size details.\n * @param configSizes The target display size string (e.g. '900px').\n */\nexport function getBestImageUrl(\n\tfeaturedMedia: Attachment | MediaItem,\n\tconfigSizes?: string\n): string {\n\tconst sizes = featuredMedia?.media_details?.sizes;\n\tif ( ! sizes ) {\n\t\treturn featuredMedia.source_url;\n\t}\n\n\tconst sizeEntries = Object.values( sizes );\n\n\tif ( ! sizeEntries.length ) {\n\t\treturn featuredMedia.source_url;\n\t}\n\n\t// Parse target width from config.sizes (e.g. '900px' \u2192 900).\n\tconst targetWidth = configSizes ? parseInt( configSizes, 10 ) : NaN;\n\n\tif ( ! Number.isNaN( targetWidth ) ) {\n\t\t// Filter to entries that have a valid numeric width.\n\t\tconst validEntries = sizeEntries.filter(\n\t\t\t( s ) => typeof s.width === 'number' && ! Number.isNaN( s.width )\n\t\t);\n\n\t\tif ( ! validEntries.length ) {\n\t\t\treturn featuredMedia.source_url;\n\t\t}\n\n\t\t// Sort ascending by width.\n\t\tconst sorted = [ ...validEntries ].sort(\n\t\t\t( a, b ) => a.width - b.width\n\t\t);\n\t\t// Pick the smallest size that is >= target width.\n\t\tconst match = sorted.find( ( s ) => s.width >= targetWidth );\n\t\tif ( match ) {\n\t\t\treturn match.source_url;\n\t\t}\n\t\t// No size large enough \u2014 use the largest available.\n\t\treturn sorted[ sorted.length - 1 ].source_url;\n\t}\n\n\t// If we can't parse the target, fall back to source_url.\n\treturn featuredMedia.source_url;\n}\n\nfunction FallbackView( {\n\titem,\n\tfilename,\n}: {\n\titem: MediaItem;\n\tfilename: string;\n} ) {\n\treturn (\n\t\t<div className=\"dataviews-media-field__media-thumbnail\">\n\t\t\t<VStack\n\t\t\t\tjustify=\"center\"\n\t\t\t\talignment=\"center\"\n\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail__stack\"\n\t\t\t\tspacing={ 0 }\n\t\t\t>\n\t\t\t\t<Icon\n\t\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail--icon\"\n\t\t\t\t\ticon={ getMediaTypeFromMimeType( item.mime_type ).icon }\n\t\t\t\t\tsize={ 24 }\n\t\t\t\t/>\n\t\t\t\t{ !! filename && (\n\t\t\t\t\t<div className=\"dataviews-media-field__media-thumbnail__filename\">\n\t\t\t\t\t\t<Truncate className=\"dataviews-media-field__media-thumbnail__filename__truncate\">\n\t\t\t\t\t\t\t{ filename }\n\t\t\t\t\t\t</Truncate>\n\t\t\t\t\t</div>\n\t\t\t\t) }\n\t\t\t</VStack>\n\t\t</div>\n\t);\n}\n\nfunction ImageView( {\n\titem,\n\tconfigSizes,\n\tonError,\n}: {\n\titem: Attachment | MediaItem;\n\tconfigSizes?: string;\n\tonError: () => void;\n} ) {\n\tconst imageUrl = getBestImageUrl( item, configSizes );\n\n\t/*\n\t * Use three states to avoid fade-in animation for cached images:\n\t * 'instant' = image already cached, 'loading' = waiting, 'loaded' = just finished.\n\t *\n\t * useLayoutEffect runs synchronously after DOM mutations but before paint,\n\t * so we can check img.complete to detect disk-cached images and skip the\n\t * fade-in animation entirely.\n\t */\n\tconst imgRef = useRef< HTMLImageElement >( null );\n\tconst [ loadingState, setLoadingState ] = useState<\n\t\t'instant' | 'loading' | 'loaded'\n\t>( 'loading' );\n\n\tuseLayoutEffect( () => {\n\t\tif ( imgRef.current?.complete ) {\n\t\t\tsetLoadingState( 'instant' );\n\t\t} else {\n\t\t\tsetLoadingState( 'loading' );\n\t\t}\n\t}, [ imageUrl ] );\n\n\tconst handleLoad = () => {\n\t\tif ( loadingState === 'loading' ) {\n\t\t\tsetLoadingState( 'loaded' );\n\t\t}\n\t};\n\n\treturn (\n\t\t<div\n\t\t\tclassName={ clsx( 'dataviews-media-field__media-thumbnail', {\n\t\t\t\t'is-loading': loadingState === 'loading',\n\t\t\t\t'is-loaded': loadingState === 'loaded',\n\t\t\t} ) }\n\t\t>\n\t\t\t<img\n\t\t\t\tref={ imgRef }\n\t\t\t\tclassName=\"dataviews-media-field__media-thumbnail--image\"\n\t\t\t\tsrc={ imageUrl }\n\t\t\t\talt={ item.alt_text || item.title.raw }\n\t\t\t\tonLoad={ handleLoad }\n\t\t\t\tonError={ onError }\n\t\t\t\tloading=\"lazy\"\n\t\t\t/>\n\t\t</div>\n\t);\n}\n\nexport default function MediaThumbnailView( {\n\titem,\n\tconfig,\n}: DataViewRenderFieldProps< MediaItem > ) {\n\tconst [ imageError, setImageError ] = useState( false );\n\n\tconst _featuredMedia = useSelect(\n\t\t( select ) => {\n\t\t\t// Avoid the network request if it's not needed. `featured_media` is\n\t\t\t// 0 for images and media without featured media.\n\t\t\tif ( ! item.featured_media ) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\treturn select( coreStore ).getEntityRecord< Attachment >(\n\t\t\t\t'postType',\n\t\t\t\t'attachment',\n\t\t\t\titem.featured_media\n\t\t\t);\n\t\t},\n\t\t[ item.featured_media ]\n\t);\n\tconst featuredMedia = item.featured_media ? _featuredMedia : item;\n\n\t// Fetching.\n\tif ( ! featuredMedia ) {\n\t\treturn null;\n\t}\n\n\tconst filename = getFilename( featuredMedia.source_url || '' );\n\n\t// Show fallback if image failed to load or if not an image type.\n\tif (\n\t\timageError ||\n\t\tgetMediaTypeFromMimeType( featuredMedia.mime_type ).type !== 'image'\n\t) {\n\t\treturn (\n\t\t\t<FallbackView item={ featuredMedia } filename={ filename || '' } />\n\t\t);\n\t}\n\n\treturn (\n\t\t<ImageView\n\t\t\titem={ featuredMedia }\n\t\t\tconfigSizes={ config?.sizes }\n\t\t\tonError={ () => setImageError( true ) }\n\t\t/>\n\t);\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -137,6 +137,17 @@
|
|
|
137
137
|
position: relative;
|
|
138
138
|
height: 100%;
|
|
139
139
|
}
|
|
140
|
+
@media not (prefers-reduced-motion) {
|
|
141
|
+
.dataviews-media-field__media-thumbnail.is-loading img, .dataviews-media-field__media-thumbnail.is-loaded img {
|
|
142
|
+
transition: opacity 0.1s linear;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
.dataviews-media-field__media-thumbnail.is-loading img {
|
|
146
|
+
opacity: 0;
|
|
147
|
+
}
|
|
148
|
+
.dataviews-media-field__media-thumbnail.is-loaded img {
|
|
149
|
+
opacity: 1;
|
|
150
|
+
}
|
|
140
151
|
|
|
141
152
|
.dataviews-media-field__media-thumbnail--image {
|
|
142
153
|
display: block;
|
package/build-style/style.css
CHANGED
|
@@ -137,6 +137,17 @@
|
|
|
137
137
|
position: relative;
|
|
138
138
|
height: 100%;
|
|
139
139
|
}
|
|
140
|
+
@media not (prefers-reduced-motion) {
|
|
141
|
+
.dataviews-media-field__media-thumbnail.is-loading img, .dataviews-media-field__media-thumbnail.is-loaded img {
|
|
142
|
+
transition: opacity 0.1s linear;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
.dataviews-media-field__media-thumbnail.is-loading img {
|
|
146
|
+
opacity: 0;
|
|
147
|
+
}
|
|
148
|
+
.dataviews-media-field__media-thumbnail.is-loaded img {
|
|
149
|
+
opacity: 1;
|
|
150
|
+
}
|
|
140
151
|
|
|
141
152
|
.dataviews-media-field__media-thumbnail--image {
|
|
142
153
|
display: block;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"get-best-image-url.test.d.ts","sourceRoot":"","sources":["../../../src/media_thumbnail/test/get-best-image-url.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,4 +1,14 @@
|
|
|
1
|
+
import type { Attachment } from '@wordpress/core-data';
|
|
1
2
|
import type { DataViewRenderFieldProps } from '@wordpress/dataviews';
|
|
2
3
|
import type { MediaItem } from '../types';
|
|
4
|
+
/**
|
|
5
|
+
* Given the available image sizes and a target display width, returns the URL
|
|
6
|
+
* of the smallest size whose width is >= the target. Falls back to the largest
|
|
7
|
+
* available size, or the original source_url.
|
|
8
|
+
*
|
|
9
|
+
* @param featuredMedia The media item with size details.
|
|
10
|
+
* @param configSizes The target display size string (e.g. '900px').
|
|
11
|
+
*/
|
|
12
|
+
export declare function getBestImageUrl(featuredMedia: Attachment | MediaItem, configSizes?: string): string;
|
|
3
13
|
export default function MediaThumbnailView({ item, config, }: DataViewRenderFieldProps<MediaItem>): import("react").JSX.Element | null;
|
|
4
14
|
//# sourceMappingURL=view.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"view.d.ts","sourceRoot":"","sources":["../../src/media_thumbnail/view.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"view.d.ts","sourceRoot":"","sources":["../../src/media_thumbnail/view.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,sBAAsB,CAAC;AAKrE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE1C;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC9B,aAAa,EAAE,UAAU,GAAG,SAAS,EACrC,WAAW,CAAC,EAAE,MAAM,GAClB,MAAM,CAwCR;AA4FD,MAAM,CAAC,OAAO,UAAU,kBAAkB,CAAE,EAC3C,IAAI,EACJ,MAAM,GACN,EAAE,wBAAwB,CAAE,SAAS,CAAE,sCA4CvC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wordpress/media-fields",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Reusable field definitions for displaying and editing media attachment properties in WordPress.",
|
|
5
5
|
"author": "The WordPress Contributors",
|
|
6
6
|
"license": "GPL-2.0-or-later",
|
|
@@ -49,18 +49,18 @@
|
|
|
49
49
|
"src/**/*.scss"
|
|
50
50
|
],
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@wordpress/base-styles": "^6.
|
|
53
|
-
"@wordpress/components": "^32.3.
|
|
54
|
-
"@wordpress/compose": "^7.
|
|
55
|
-
"@wordpress/core-data": "^7.
|
|
56
|
-
"@wordpress/data": "^10.
|
|
57
|
-
"@wordpress/dataviews": "^
|
|
58
|
-
"@wordpress/date": "^5.
|
|
59
|
-
"@wordpress/element": "^6.
|
|
60
|
-
"@wordpress/i18n": "^6.
|
|
61
|
-
"@wordpress/icons": "^11.
|
|
62
|
-
"@wordpress/primitives": "^4.
|
|
63
|
-
"@wordpress/url": "^4.
|
|
52
|
+
"@wordpress/base-styles": "^6.17.0",
|
|
53
|
+
"@wordpress/components": "^32.3.0",
|
|
54
|
+
"@wordpress/compose": "^7.41.0",
|
|
55
|
+
"@wordpress/core-data": "^7.41.0",
|
|
56
|
+
"@wordpress/data": "^10.41.0",
|
|
57
|
+
"@wordpress/dataviews": "^13.0.0",
|
|
58
|
+
"@wordpress/date": "^5.41.0",
|
|
59
|
+
"@wordpress/element": "^6.41.0",
|
|
60
|
+
"@wordpress/i18n": "^6.14.0",
|
|
61
|
+
"@wordpress/icons": "^11.8.0",
|
|
62
|
+
"@wordpress/primitives": "^4.41.0",
|
|
63
|
+
"@wordpress/url": "^4.41.0",
|
|
64
64
|
"clsx": "2.1.1"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
@@ -73,5 +73,5 @@
|
|
|
73
73
|
"publishConfig": {
|
|
74
74
|
"access": "public"
|
|
75
75
|
},
|
|
76
|
-
"gitHead": "
|
|
76
|
+
"gitHead": "8bfc179b9aed74c0a6dd6e8edf7a49e40e4f87cc"
|
|
77
77
|
}
|
|
@@ -7,6 +7,27 @@
|
|
|
7
7
|
align-items: center;
|
|
8
8
|
position: relative;
|
|
9
9
|
height: 100%;
|
|
10
|
+
|
|
11
|
+
&.is-loading,
|
|
12
|
+
&.is-loaded {
|
|
13
|
+
img {
|
|
14
|
+
@media not (prefers-reduced-motion) {
|
|
15
|
+
transition: opacity 0.1s linear;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
&.is-loading {
|
|
21
|
+
img {
|
|
22
|
+
opacity: 0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&.is-loaded {
|
|
27
|
+
img {
|
|
28
|
+
opacity: 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
10
31
|
}
|
|
11
32
|
|
|
12
33
|
.dataviews-media-field__media-thumbnail--image {
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { getBestImageUrl } from '../view';
|
|
5
|
+
|
|
6
|
+
type Media = Parameters< typeof getBestImageUrl >[ 0 ];
|
|
7
|
+
|
|
8
|
+
const baseMedia: Media = {
|
|
9
|
+
id: 1,
|
|
10
|
+
date: '2024-01-01T00:00:00',
|
|
11
|
+
date_gmt: '2024-01-01T00:00:00',
|
|
12
|
+
guid: {
|
|
13
|
+
raw: 'https://example.com/?attachment_id=1',
|
|
14
|
+
rendered: 'https://example.com/?attachment_id=1',
|
|
15
|
+
},
|
|
16
|
+
modified: '2024-01-01T00:00:00',
|
|
17
|
+
modified_gmt: '2024-01-01T00:00:00',
|
|
18
|
+
slug: 'test-image',
|
|
19
|
+
status: 'publish',
|
|
20
|
+
type: 'attachment',
|
|
21
|
+
link: 'https://example.com/test-image/',
|
|
22
|
+
title: { raw: 'Test Image', rendered: 'Test Image' },
|
|
23
|
+
author: 1,
|
|
24
|
+
featured_media: 0,
|
|
25
|
+
comment_status: 'open',
|
|
26
|
+
ping_status: 'closed',
|
|
27
|
+
template: '',
|
|
28
|
+
permalink_template: '',
|
|
29
|
+
generated_slug: 'test-image',
|
|
30
|
+
class_list: [ 'attachment' ],
|
|
31
|
+
caption: { raw: '', rendered: '' },
|
|
32
|
+
description: { raw: '', rendered: '' },
|
|
33
|
+
alt_text: 'A test image',
|
|
34
|
+
media_type: 'image',
|
|
35
|
+
mime_type: 'image/jpeg',
|
|
36
|
+
post: null,
|
|
37
|
+
source_url: 'https://example.com/original.jpg',
|
|
38
|
+
missing_image_sizes: [],
|
|
39
|
+
meta: {},
|
|
40
|
+
media_details: {
|
|
41
|
+
sizes: {
|
|
42
|
+
thumbnail: {
|
|
43
|
+
file: 'original-150x150.jpg',
|
|
44
|
+
width: 150,
|
|
45
|
+
height: 150,
|
|
46
|
+
mime_type: 'image/jpeg',
|
|
47
|
+
source_url: 'https://example.com/thumb.jpg',
|
|
48
|
+
},
|
|
49
|
+
medium: {
|
|
50
|
+
file: 'original-300x200.jpg',
|
|
51
|
+
width: 300,
|
|
52
|
+
height: 200,
|
|
53
|
+
mime_type: 'image/jpeg',
|
|
54
|
+
source_url: 'https://example.com/medium.jpg',
|
|
55
|
+
},
|
|
56
|
+
large: {
|
|
57
|
+
file: 'original-1024x683.jpg',
|
|
58
|
+
width: 1024,
|
|
59
|
+
height: 683,
|
|
60
|
+
mime_type: 'image/jpeg',
|
|
61
|
+
source_url: 'https://example.com/large.jpg',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe( 'getBestImageUrl', () => {
|
|
68
|
+
describe( 'when media_details.sizes is unavailable', () => {
|
|
69
|
+
it( 'returns source_url when media_details has no sizes', () => {
|
|
70
|
+
const media = {
|
|
71
|
+
...baseMedia,
|
|
72
|
+
media_details: { sizes: {} },
|
|
73
|
+
};
|
|
74
|
+
expect( getBestImageUrl( media ) ).toBe( baseMedia.source_url );
|
|
75
|
+
} );
|
|
76
|
+
} );
|
|
77
|
+
|
|
78
|
+
describe( 'when configSizes is not provided or unparseable', () => {
|
|
79
|
+
it( 'returns source_url when configSizes is undefined', () => {
|
|
80
|
+
expect( getBestImageUrl( baseMedia ) ).toBe( baseMedia.source_url );
|
|
81
|
+
} );
|
|
82
|
+
|
|
83
|
+
it( 'returns source_url when configSizes is not a number', () => {
|
|
84
|
+
expect( getBestImageUrl( baseMedia, 'auto' ) ).toBe(
|
|
85
|
+
baseMedia.source_url
|
|
86
|
+
);
|
|
87
|
+
} );
|
|
88
|
+
} );
|
|
89
|
+
|
|
90
|
+
describe( 'size selection with a valid target width', () => {
|
|
91
|
+
it( 'picks the smallest size >= target width', () => {
|
|
92
|
+
expect( getBestImageUrl( baseMedia, '200px' ) ).toBe(
|
|
93
|
+
'https://example.com/medium.jpg'
|
|
94
|
+
);
|
|
95
|
+
} );
|
|
96
|
+
|
|
97
|
+
it( 'picks an exact match', () => {
|
|
98
|
+
expect( getBestImageUrl( baseMedia, '300px' ) ).toBe(
|
|
99
|
+
'https://example.com/medium.jpg'
|
|
100
|
+
);
|
|
101
|
+
} );
|
|
102
|
+
|
|
103
|
+
it( 'picks the smallest available size when target is very small', () => {
|
|
104
|
+
expect( getBestImageUrl( baseMedia, '50px' ) ).toBe(
|
|
105
|
+
'https://example.com/thumb.jpg'
|
|
106
|
+
);
|
|
107
|
+
} );
|
|
108
|
+
|
|
109
|
+
it( 'falls back to the largest size when target exceeds all sizes', () => {
|
|
110
|
+
expect( getBestImageUrl( baseMedia, '2000px' ) ).toBe(
|
|
111
|
+
'https://example.com/large.jpg'
|
|
112
|
+
);
|
|
113
|
+
} );
|
|
114
|
+
} );
|
|
115
|
+
|
|
116
|
+
// These tests deliberately omit required Size fields (width, height, etc.)
|
|
117
|
+
// to verify runtime defensiveness, so we cast via `unknown` to bypass TS.
|
|
118
|
+
describe( 'when size entries have missing or invalid widths', () => {
|
|
119
|
+
it( 'skips entries without a width and selects from valid ones', () => {
|
|
120
|
+
const media = {
|
|
121
|
+
...baseMedia,
|
|
122
|
+
media_details: {
|
|
123
|
+
sizes: {
|
|
124
|
+
broken: {
|
|
125
|
+
source_url: 'https://example.com/broken.jpg',
|
|
126
|
+
},
|
|
127
|
+
medium: {
|
|
128
|
+
file: 'original-300x200.jpg',
|
|
129
|
+
width: 300,
|
|
130
|
+
height: 200,
|
|
131
|
+
mime_type: 'image/jpeg',
|
|
132
|
+
source_url: 'https://example.com/medium.jpg',
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
} as unknown as Media;
|
|
137
|
+
expect( getBestImageUrl( media, '200px' ) ).toBe(
|
|
138
|
+
'https://example.com/medium.jpg'
|
|
139
|
+
);
|
|
140
|
+
} );
|
|
141
|
+
|
|
142
|
+
it( 'falls back to source_url when all entries lack a valid width', () => {
|
|
143
|
+
const media = {
|
|
144
|
+
...baseMedia,
|
|
145
|
+
media_details: {
|
|
146
|
+
sizes: {
|
|
147
|
+
broken: {
|
|
148
|
+
source_url: 'https://example.com/broken.jpg',
|
|
149
|
+
},
|
|
150
|
+
also_broken: {
|
|
151
|
+
source_url: 'https://example.com/also-broken.jpg',
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
} as unknown as Media;
|
|
156
|
+
expect( getBestImageUrl( media, '200px' ) ).toBe(
|
|
157
|
+
baseMedia.source_url
|
|
158
|
+
);
|
|
159
|
+
} );
|
|
160
|
+
} );
|
|
161
|
+
} );
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External dependencies
|
|
3
|
+
*/
|
|
4
|
+
import clsx from 'clsx';
|
|
5
|
+
|
|
1
6
|
/**
|
|
2
7
|
* WordPress dependencies
|
|
3
8
|
*/
|
|
@@ -8,7 +13,7 @@ import {
|
|
|
8
13
|
__experimentalVStack as VStack,
|
|
9
14
|
Icon,
|
|
10
15
|
} from '@wordpress/components';
|
|
11
|
-
import { useState } from '@wordpress/element';
|
|
16
|
+
import { useState, useRef, useLayoutEffect } from '@wordpress/element';
|
|
12
17
|
import type { Attachment } from '@wordpress/core-data';
|
|
13
18
|
import { getFilename } from '@wordpress/url';
|
|
14
19
|
import type { DataViewRenderFieldProps } from '@wordpress/dataviews';
|
|
@@ -18,6 +23,59 @@ import type { DataViewRenderFieldProps } from '@wordpress/dataviews';
|
|
|
18
23
|
import { getMediaTypeFromMimeType } from '../utils/get-media-type-from-mime-type';
|
|
19
24
|
import type { MediaItem } from '../types';
|
|
20
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Given the available image sizes and a target display width, returns the URL
|
|
28
|
+
* of the smallest size whose width is >= the target. Falls back to the largest
|
|
29
|
+
* available size, or the original source_url.
|
|
30
|
+
*
|
|
31
|
+
* @param featuredMedia The media item with size details.
|
|
32
|
+
* @param configSizes The target display size string (e.g. '900px').
|
|
33
|
+
*/
|
|
34
|
+
export function getBestImageUrl(
|
|
35
|
+
featuredMedia: Attachment | MediaItem,
|
|
36
|
+
configSizes?: string
|
|
37
|
+
): string {
|
|
38
|
+
const sizes = featuredMedia?.media_details?.sizes;
|
|
39
|
+
if ( ! sizes ) {
|
|
40
|
+
return featuredMedia.source_url;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sizeEntries = Object.values( sizes );
|
|
44
|
+
|
|
45
|
+
if ( ! sizeEntries.length ) {
|
|
46
|
+
return featuredMedia.source_url;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Parse target width from config.sizes (e.g. '900px' → 900).
|
|
50
|
+
const targetWidth = configSizes ? parseInt( configSizes, 10 ) : NaN;
|
|
51
|
+
|
|
52
|
+
if ( ! Number.isNaN( targetWidth ) ) {
|
|
53
|
+
// Filter to entries that have a valid numeric width.
|
|
54
|
+
const validEntries = sizeEntries.filter(
|
|
55
|
+
( s ) => typeof s.width === 'number' && ! Number.isNaN( s.width )
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if ( ! validEntries.length ) {
|
|
59
|
+
return featuredMedia.source_url;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Sort ascending by width.
|
|
63
|
+
const sorted = [ ...validEntries ].sort(
|
|
64
|
+
( a, b ) => a.width - b.width
|
|
65
|
+
);
|
|
66
|
+
// Pick the smallest size that is >= target width.
|
|
67
|
+
const match = sorted.find( ( s ) => s.width >= targetWidth );
|
|
68
|
+
if ( match ) {
|
|
69
|
+
return match.source_url;
|
|
70
|
+
}
|
|
71
|
+
// No size large enough — use the largest available.
|
|
72
|
+
return sorted[ sorted.length - 1 ].source_url;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// If we can't parse the target, fall back to source_url.
|
|
76
|
+
return featuredMedia.source_url;
|
|
77
|
+
}
|
|
78
|
+
|
|
21
79
|
function FallbackView( {
|
|
22
80
|
item,
|
|
23
81
|
filename,
|
|
@@ -50,6 +108,64 @@ function FallbackView( {
|
|
|
50
108
|
);
|
|
51
109
|
}
|
|
52
110
|
|
|
111
|
+
function ImageView( {
|
|
112
|
+
item,
|
|
113
|
+
configSizes,
|
|
114
|
+
onError,
|
|
115
|
+
}: {
|
|
116
|
+
item: Attachment | MediaItem;
|
|
117
|
+
configSizes?: string;
|
|
118
|
+
onError: () => void;
|
|
119
|
+
} ) {
|
|
120
|
+
const imageUrl = getBestImageUrl( item, configSizes );
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* Use three states to avoid fade-in animation for cached images:
|
|
124
|
+
* 'instant' = image already cached, 'loading' = waiting, 'loaded' = just finished.
|
|
125
|
+
*
|
|
126
|
+
* useLayoutEffect runs synchronously after DOM mutations but before paint,
|
|
127
|
+
* so we can check img.complete to detect disk-cached images and skip the
|
|
128
|
+
* fade-in animation entirely.
|
|
129
|
+
*/
|
|
130
|
+
const imgRef = useRef< HTMLImageElement >( null );
|
|
131
|
+
const [ loadingState, setLoadingState ] = useState<
|
|
132
|
+
'instant' | 'loading' | 'loaded'
|
|
133
|
+
>( 'loading' );
|
|
134
|
+
|
|
135
|
+
useLayoutEffect( () => {
|
|
136
|
+
if ( imgRef.current?.complete ) {
|
|
137
|
+
setLoadingState( 'instant' );
|
|
138
|
+
} else {
|
|
139
|
+
setLoadingState( 'loading' );
|
|
140
|
+
}
|
|
141
|
+
}, [ imageUrl ] );
|
|
142
|
+
|
|
143
|
+
const handleLoad = () => {
|
|
144
|
+
if ( loadingState === 'loading' ) {
|
|
145
|
+
setLoadingState( 'loaded' );
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
className={ clsx( 'dataviews-media-field__media-thumbnail', {
|
|
152
|
+
'is-loading': loadingState === 'loading',
|
|
153
|
+
'is-loaded': loadingState === 'loaded',
|
|
154
|
+
} ) }
|
|
155
|
+
>
|
|
156
|
+
<img
|
|
157
|
+
ref={ imgRef }
|
|
158
|
+
className="dataviews-media-field__media-thumbnail--image"
|
|
159
|
+
src={ imageUrl }
|
|
160
|
+
alt={ item.alt_text || item.title.raw }
|
|
161
|
+
onLoad={ handleLoad }
|
|
162
|
+
onError={ onError }
|
|
163
|
+
loading="lazy"
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
53
169
|
export default function MediaThumbnailView( {
|
|
54
170
|
item,
|
|
55
171
|
config,
|
|
@@ -91,31 +207,10 @@ export default function MediaThumbnailView( {
|
|
|
91
207
|
}
|
|
92
208
|
|
|
93
209
|
return (
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
featuredMedia?.media_details?.sizes
|
|
100
|
-
? (
|
|
101
|
-
Object.values(
|
|
102
|
-
featuredMedia.media_details.sizes
|
|
103
|
-
) as Array< {
|
|
104
|
-
source_url: string;
|
|
105
|
-
width: number;
|
|
106
|
-
} >
|
|
107
|
-
)
|
|
108
|
-
.map(
|
|
109
|
-
( size ) =>
|
|
110
|
-
`${ size.source_url } ${ size.width }w`
|
|
111
|
-
)
|
|
112
|
-
.join( ', ' )
|
|
113
|
-
: undefined
|
|
114
|
-
}
|
|
115
|
-
sizes={ config?.sizes || '100vw' }
|
|
116
|
-
alt={ featuredMedia.alt_text || featuredMedia.title.raw }
|
|
117
|
-
onError={ () => setImageError( true ) }
|
|
118
|
-
/>
|
|
119
|
-
</div>
|
|
210
|
+
<ImageView
|
|
211
|
+
item={ featuredMedia }
|
|
212
|
+
configSizes={ config?.sizes }
|
|
213
|
+
onError={ () => setImageError( true ) }
|
|
214
|
+
/>
|
|
120
215
|
);
|
|
121
216
|
}
|