Skip to content

Commit

Permalink
feat(image): support .avif and .webp (#2182)
Browse files Browse the repository at this point in the history
  • Loading branch information
chaishi committed Feb 27, 2023
1 parent adefdf7 commit 25ad02a
Show file tree
Hide file tree
Showing 14 changed files with 243 additions and 31 deletions.
2 changes: 1 addition & 1 deletion src/_common
Submodule _common updated 1 files
+9 −3 docs/web/api/image.md
9 changes: 9 additions & 0 deletions src/config-provider/_example/others.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
<t-step-item title="Last Step" content="You haven't finish this step."></t-step-item>
</t-steps>
<br /><br />

<t-image src="1.jpg" fit="scale-down" style="width: 300px"></t-image>
</t-config-provider>
</template>

Expand Down Expand Up @@ -138,6 +140,13 @@ export default {
return {
// 全局特性配置,可以引入英文默认配置 enConfig,还可以在默认配置的基础上进行自定义配置
globalConfig: merge(enConfig, {
image: {
// 全局替换图片地址
replaceImageSrc(params) {
console.log(params);
return 'https://tdesign.gtimg.com/demo/demo-image-1.png';
},
},
form: {
requiredMark: false,
},
Expand Down
3 changes: 2 additions & 1 deletion src/config-provider/config-provider.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,14 +292,15 @@ copyText | String | - | \- | N

name | type | default | description | required
-- | -- | -- | -- | --
`MessageOptions` | \- | - | \- | N
`MessageOptions` | \- | - | extends `MessageOptions` | N

### ImageConfig

name | type | default | description | required
-- | -- | -- | -- | --
errorText | String | - | loading text, default value is "Error" | N
loadingText | String | - | loading text, default value is "loading" | N
replaceImageSrc | Function | - | replace all `src` attribute of images。Typescript:`(params: ImageProps) => string`[Image API Documents](./image?tab=api)[see more ts definition](https://github.com/Tencent/tdesign-vue/tree/develop/src/config-provider/type.ts) | N

### ImageViewerConfig

Expand Down
3 changes: 2 additions & 1 deletion src/config-provider/config-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,14 +326,15 @@ copyText | String | - | 语言配置,“复制链接” 描述文本 | N

名称 | 类型 | 默认值 | 说明 | 必传
-- | -- | -- | -- | --
`MessageOptions` | \- | - | 继承 `MessageOptions` 中的全部 API | N
`MessageOptions` | \- | - | 继承 `MessageOptions` 中的全部属性 | N

### ImageConfig

名称 | 类型 | 默认值 | 说明 | 必传
-- | -- | -- | -- | --
errorText | String | - | 图片加载失败显示的文本,中文默认为“图片无法显示” | N
loadingText | String | - | 图片加载中显示的文本,中文默认为“图片加载中” | N
replaceImageSrc | Function | - | 统一替换图片 `src` 地址,参数为组件的全部属性,返回值为新的图片地址。TS 类型:`(params: ImageProps) => string`[Image API Documents](./image?tab=api)[详细类型定义](https://github.com/Tencent/tdesign-vue/tree/develop/src/config-provider/type.ts) | N

### ImageViewerConfig

Expand Down
5 changes: 5 additions & 0 deletions src/config-provider/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { CalendarController } from '../calendar';
import { ButtonProps } from '../button';
import { FormErrorMessage } from '../form';
import { MessageOptions } from '../message';
import { ImageProps } from '../image';
import { TNode } from '../common';

export interface GlobalConfigProvider {
Expand Down Expand Up @@ -833,6 +834,10 @@ export interface ImageConfig {
* @default ''
*/
loadingText?: string;
/**
* 统一替换图片 `src` 地址,参数为组件的全部属性,返回值为新的图片地址
*/
replaceImageSrc?: (params: ImageProps) => string;
}

export interface ImageViewerConfig {
Expand Down
41 changes: 30 additions & 11 deletions src/image/__tests__/vitest-image.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ describe('Image Component', () => {
it('props.alt works fine', () => {
const wrapper = mount({
render() {
return <Image alt="text image load failed" src="https://www.error.img.com"></Image>;
return <Image alt={'text image load failed'} src={'https://www.error.img.com'}></Image>;
},
}).find('img');
expect(wrapper.attributes('alt')).toBe('text image load failed');
Expand All @@ -24,7 +24,7 @@ describe('Image Component', () => {
const wrapper = mount({
render() {
return (
<Image error={(h) => <span class="custom-node">TNode</span>} src="https://this.is.an.error.img.com"></Image>
<Image error={(h) => <span class="custom-node">TNode</span>} src={'https://this.is.an.error.img.com'}></Image>
);
},
});
Expand All @@ -40,7 +40,7 @@ describe('Image Component', () => {
return (
<Image
scopedSlots={{ error: (h) => <span class="custom-node">TNode</span> }}
src="https://this.is.an.error.img.com"
src={'https://this.is.an.error.img.com'}
></Image>
);
},
Expand Down Expand Up @@ -140,7 +140,7 @@ describe('Image Component', () => {
it('slots.overlay-content works fine', () => {
const wrapper = mount({
render() {
return <Image scopedSlots={{ 'overlay-content': () => <span class="custom-node">TNode</span> }}></Image>;
return <Image scopedSlots={{ 'overlay-content': (h) => <span class="custom-node">TNode</span> }}></Image>;
},
});
expect(wrapper.find('.custom-node').exists()).toBeTruthy();
Expand All @@ -156,7 +156,7 @@ describe('Image Component', () => {
wrapper.find('.t-image__wrapper').trigger('mouseenter');
await wrapper.vm.$nextTick();
expect(wrapper.find('.t-image__overlay-content').exists()).toBeTruthy();
expect(wrapper.findAll('.t-image__overlay-content--hidden').length).toBe(0);
expect(wrapper.find('.t-image__overlay-content--hidden').exists()).toBeFalsy();
wrapper.find('.t-image__wrapper').trigger('mouseleave');
await wrapper.vm.$nextTick();
expect(wrapper.find('.t-image__overlay-content--hidden').exists()).toBeTruthy();
Expand Down Expand Up @@ -213,34 +213,53 @@ describe('Image Component', () => {
});
});

it('props.srcset is equal to {\'image/avif\': \'https://tdesign.gtimg.com/img/tdesign-image.avif\',\'image/webp\': \'https://tdesign.gtimg.com/img/tdesign-image.webp\'}', () => {
const wrapper = mount({
render() {
return (
<Image
srcset={{
'image/avif': 'https://tdesign.gtimg.com/img/tdesign-image.avif',
'image/webp': 'https://tdesign.gtimg.com/img/tdesign-image.webp',
}}
></Image>
);
},
});
const domWrapper = wrapper.find('picture > source');
expect(domWrapper.attributes('srcset')).toBe('https://tdesign.gtimg.com/img/tdesign-image.avif');
const domWrapper1 = wrapper.find('picture > source:nth-child(2)');
expect(domWrapper1.attributes('srcset')).toBe('https://tdesign.gtimg.com/img/tdesign-image.webp');
});

it('events.error works fine', async () => {
const onErrorFn = vi.fn();
const wrapper = mount({
render() {
return <Image src="https://load-failed-img.png" on={{ error: onErrorFn }}></Image>;
return <Image src={'https://load-failed-img.png'} on={{ error: onErrorFn }}></Image>;
},
});
const imgDom = wrapper.find('img').element;
simulateImageEvent(imgDom, 'error');
await wrapper.vm.$nextTick();
expect(wrapper.find('.t-image__error').exists()).toBeTruthy();
expect(wrapper.find('.t-icon-image-error').exists()).toBeTruthy();
expect(onErrorFn).toHaveBeenCalled(1);
expect(onErrorFn).toHaveBeenCalled();
expect(onErrorFn.mock.calls[0][0].e.type).toBe('error');
});

it('events.load works fine', async () => {
const onLoadFn1 = vi.fn();
const wrapper = mount({
render() {
return <Image src="https://tdesign.gtimg.com/demo/demo-image-1.png" on={{ load: onLoadFn1 }}></Image>;
return <Image src={'https://tdesign.gtimg.com/demo/demo-image-1.png'} on={{ load: onLoadFn1 }}></Image>;
},
});
await wrapper.vm.$nextTick();
const imgDom = wrapper.find('img').element;
simulateImageEvent(imgDom, 'load');
const imgDom1 = wrapper.find('img').element;
simulateImageEvent(imgDom1, 'load');
await wrapper.vm.$nextTick();
expect(onLoadFn1).toHaveBeenCalled(1);
expect(onLoadFn1).toHaveBeenCalled();
expect(onLoadFn1.mock.calls[0][0].e.type).toBe('load');
});
});
15 changes: 15 additions & 0 deletions src/image/_example/avif.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<t-space direction="vertical" align="center">
<t-image
src="https://tdesign.gtimg.com/demo/demo-image-1.png"
:srcset="{
'image/avif': 'https://tdesign.gtimg.com/img/tdesign-image.avif',
'image/webp': 'https://tdesign.gtimg.com/img/tdesign-image.webp',
}"
shape="square"
:style="{ maxWidth: '100%' }"
fit="scale-down"
/>
<span>.avif / .webp</span>
</t-space>
</template>
1 change: 1 addition & 0 deletions src/image/image.en-US.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ placeholder | String / Slot / Function | - | Typescript:`string \| TNode`。[s
position | String | center | \- | N
shape | String | square | options:circle/round/square | N
src | String | - | \- | N
srcset | Object | - | for `.avif` and `.webp` image url。Typescript:`ImageSrcset` `interface ImageSrcset { 'image/avif': string; 'image/webp': string; }`[see more ts definition](https://github.com/Tencent/tdesign-vue/tree/develop/src/image/type.ts) | N
onError | Function | | Typescript:`(context: { e: ImageEvent }) => void`<br/>trigger on image load failed | N
onLoad | Function | | Typescript:`(context: { e: ImageEvent }) => void`<br/>trigger on image loaded | N

Expand Down
1 change: 1 addition & 0 deletions src/image/image.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ placeholder | String / Slot / Function | - | 占位元素,展示层级低于 `
position | String | center | 等同于原生的 object-position 属性,可选值为 top right bottom left 或 string,可以自定义任何单位,px 或者 百分比 | N
shape | String | square | 图片圆角类型。可选项:circle/round/square | N
src | String | - | 图片链接 | N
srcset | Object | - | 图片地址,支持特殊格式的图片,如 `.avif``.webp`。TS 类型:`ImageSrcset` `interface ImageSrcset { 'image/avif': string; 'image/webp': string; }`[详细类型定义](https://github.com/Tencent/tdesign-vue/tree/develop/src/image/type.ts) | N
onError | Function | | TS 类型:`(context: { e: ImageEvent }) => void`<br/>图片加载失败时触发 | N
onLoad | Function | | TS 类型:`(context: { e: ImageEvent }) => void`<br/>图片加载完成时触发 | N

Expand Down
49 changes: 33 additions & 16 deletions src/image/image.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { defineComponent, ref, watch } from '@vue/composition-api';
import {
computed, defineComponent, ref, watch,
} from '@vue/composition-api';
import omit from 'lodash/omit';
import isFunction from 'lodash/isFunction';
import { ImageErrorIcon, ImageIcon } from 'tdesign-icons-vue';
import observe from '../_common/js/utils/observe';
import { useConfig } from '../config-provider/useConfig';
Expand Down Expand Up @@ -39,13 +42,14 @@ export default defineComponent({
const { classPrefix, globalConfig } = useConfig('image');
const imageRef = ref<HTMLElement>(null);

const imageSrc = ref(props.src);
// replace image url
const imageSrc = computed(() => isFunction(globalConfig.value.replaceImageSrc) ? globalConfig.value.replaceImageSrc(props) : props.src);

watch(
() => props.src,
() => {
hasError.value = false;
isLoaded.value = false;
imageSrc.value = props.src;
},
);

Expand Down Expand Up @@ -77,8 +81,15 @@ export default defineComponent({
}
};

const imageClasses = computed(() => [
`${classPrefix.value}-image`,
`${classPrefix.value}-image--fit-${props.fit}`,
`${classPrefix.value}-image--position-${props.position}`,
]);

return {
imageRef,
imageClasses,
handleLoadImage,
classPrefix,
globalConfig,
Expand Down Expand Up @@ -127,6 +138,23 @@ export default defineComponent({
</div>
);
},

renderImageSrcset() {
return (
<picture>
{Object.entries(this.srcset).map(([type, url]) => (
<source type={type} srcset={url} />
))}
{this.src && this.renderImage(this.src)}
</picture>
);
},

renderImage(url: string) {
return (
<img src={url} onError={this.handleError} onLoad={this.handleLoad} class={this.imageClasses} alt={this.alt} />
);
},
},

render() {
Expand All @@ -148,19 +176,8 @@ export default defineComponent({
{this.renderGalleryShadow()}

{(this.hasError || !this.shouldLoad) && <div class={`${this.classPrefix}-image`} />}
{!(this.hasError || !this.shouldLoad) && (
<img
src={this.imageSrc}
onError={this.handleError}
onLoad={this.handleLoad}
class={[
`${this.classPrefix}-image`,
`${this.classPrefix}-image--fit-${this.fit}`,
`${this.classPrefix}-image--position-${this.position}`,
]}
alt={this.alt}
/>
)}
{!(this.hasError || !this.shouldLoad)
&& (this.srcset && Object.keys(this.srcset).length ? this.renderImageSrcset() : this.renderImage(this.imageSrc))}
{!(this.hasError || !this.shouldLoad) && !this.isLoaded && (
<div class={`${this.classPrefix}-image__loading`}>
{renderTNodeJSX(this, 'loading') || (
Expand Down
4 changes: 4 additions & 0 deletions src/image/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ export default {
type: String,
default: '',
},
/** 图片地址,支持特殊格式的图片,如 `.avif` 和 `.webp` */
srcset: {
type: Object as PropType<TdImageProps['srcset']>,
},
/** 图片加载失败时触发 */
onError: Function as PropType<TdImageProps['onError']>,
/** 图片加载完成时触发 */
Expand Down
9 changes: 9 additions & 0 deletions src/image/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ export interface TdImageProps {
* @default ''
*/
src?: string;
/**
* 图片地址,支持特殊格式的图片,如 `.avif` 和 `.webp`
*/
srcset?: ImageSrcset;
/**
* 图片加载失败时触发
*/
Expand All @@ -72,3 +76,8 @@ export interface TdImageProps {
*/
onLoad?: (context: { e: ImageEvent }) => void;
}

export interface ImageSrcset {
'image/avif': string;
'image/webp': string;
}
Loading

0 comments on commit 25ad02a

Please sign in to comment.