feat(image): 添加预览图加载占位符支持。Add preview image loading placeholder support.

支持通过 placeholder 属性自定义预览图的加载占位符,可以是 VNode 或布尔值。当设置为 true 时使用默认的 Spin 组件,设置为 VNode 时则渲染自定义内容。
Support customizing preview image loading placeholder through the placeholder property, which can be a VNode or boolean value. When set to true, it uses the default Spin component; when set to a VNode, it renders custom content.
pull/8322/head
shuoxian 2025-08-22 12:24:52 +08:00
parent 35c1ad9c80
commit 9f465b25a4
5 changed files with 71 additions and 7 deletions

View File

@ -8,11 +8,11 @@ title:
## zh-CN ## zh-CN
可以设置不同的预览图片 可以设置不同的预览图片可以自定义预览图的加载占位符(placeholder)
## en-US ## en-US
You can set different preview image. You can set different preview image. You can also customize the loading placeholder of preview image with VNode.
</docs> </docs>
@ -21,7 +21,31 @@ You can set different preview image.
:width="200" :width="200"
src="https://aliyuncdn.antdv.com/logo.png" src="https://aliyuncdn.antdv.com/logo.png"
:preview="{ :preview="{
src: 'https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png', src: 'http://47.100.102.7:11501/admin-api/infra/file/24/get//algorithm/execute/2025/08/20/20210628_iPhoneSE_YL_42_1755674307938_1755674703040.jpg',
placeholder: h(CustomLoadingComp),
}"
/>
<a-image
:width="200"
src="https://aliyuncdn.antdv.com/logo.png"
:preview="{
src: 'http://47.100.102.7:11501/admin-api/infra/file/24/get//algorithm/execute/2025/08/20/20210628_iPhoneSE_YL_42_1755674307938_1755674703040.jpg',
placeholder: true,
}" }"
/> />
</template> </template>
<script lang="ts" setup>
import { h } from 'vue';
import LoadingOutlined from '@ant-design/icons-vue/LoadingOutlined';
import { theme } from 'ant-design-vue';
const { token } = theme.useToken();
const CustomLoadingComp = h(LoadingOutlined, {
style: {
fontSize: '256px',
color: token.value.colorPrimary,
},
spin: true,
});
</script>

View File

@ -43,6 +43,7 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*LVQ3R5JjjJEAAA
src?: string; src?: string;
maskClassName?: string; maskClassName?: string;
current?: number; current?: number;
placeholder?: VNode | boolean;
} }
``` ```

View File

@ -193,6 +193,9 @@ export const genImagePreviewStyle: GenerateStyle<ImageToken> = (token: ImageToke
transition: `transform ${motionDurationSlow} ${motionEaseOut} 0s`, transition: `transform ${motionDurationSlow} ${motionEaseOut} 0s`,
userSelect: 'none', userSelect: 'none',
pointerEvents: 'auto', pointerEvents: 'auto',
'&-placeholder': {
position: 'absolute',
},
'&-wrapper': { '&-wrapper': {
...genBoxStyle(), ...genBoxStyle(),
@ -214,7 +217,6 @@ export const genImagePreviewStyle: GenerateStyle<ImageToken> = (token: ImageToke
}, },
}, },
}, },
[`${previewCls}-moving`]: { [`${previewCls}-moving`]: {
[`${previewCls}-preview-img`]: { [`${previewCls}-preview-img`]: {
cursor: 'grabbing', cursor: 'grabbing',

View File

@ -1,4 +1,4 @@
import type { CSSProperties, PropType } from 'vue'; import type { CSSProperties, PropType, VNode } from 'vue';
import { ref, watch, defineComponent, computed, onMounted, onUnmounted } from 'vue'; import { ref, watch, defineComponent, computed, onMounted, onUnmounted } from 'vue';
import isNumber from 'lodash-es/isNumber'; import isNumber from 'lodash-es/isNumber';
import cn from '../../_util/classNames'; import cn from '../../_util/classNames';
@ -19,6 +19,8 @@ export type ImagePreviewType = Omit<
> & { > & {
src?: string; src?: string;
visible?: boolean; visible?: boolean;
fallback?: string;
placeholder?: VNode | boolean;
onVisibleChange?: (value: boolean, prevValue: boolean) => void; onVisibleChange?: (value: boolean, prevValue: boolean) => void;
getContainer?: GetContainer | false; getContainer?: GetContainer | false;
maskClassName?: string; maskClassName?: string;
@ -79,6 +81,7 @@ const ImageInternal = defineComponent({
onVisibleChange: () => {}, onVisibleChange: () => {},
getContainer: undefined, getContainer: undefined,
}; };
return typeof props.preview === 'object' return typeof props.preview === 'object'
? mergeDefaultValue(props.preview, defaultValues) ? mergeDefaultValue(props.preview, defaultValues)
: defaultValues; : defaultValues;
@ -288,6 +291,7 @@ const ImageInternal = defineComponent({
onClose={onPreviewClose} onClose={onPreviewClose}
mousePosition={mousePosition.value} mousePosition={mousePosition.value}
src={mergedSrc} src={mergedSrc}
placeholder={preview.value.placeholder}
alt={alt} alt={alt}
getContainer={getPreviewContainer.value} getContainer={getPreviewContainer.value}
icons={icons} icons={icons}

View File

@ -7,11 +7,15 @@ import {
shallowRef, shallowRef,
watch, watch,
cloneVNode, cloneVNode,
ref,
isVNode,
nextTick,
} from 'vue'; } from 'vue';
import type { VNode, PropType } from 'vue'; import type { VNode, Ref, PropType } from 'vue';
import type { ImageStatus } from './Image';
import classnames from '../../_util/classNames'; import classnames from '../../_util/classNames';
import Dialog from '../../vc-dialog'; import Dialog from '../../vc-dialog';
import Spin from '../../spin';
import { type IDialogChildProps, dialogPropTypes } from '../../vc-dialog/IDialogPropTypes'; import { type IDialogChildProps, dialogPropTypes } from '../../vc-dialog/IDialogPropTypes';
import { getOffset } from '../../vc-util/Dom/css'; import { getOffset } from '../../vc-util/Dom/css';
import addEventListener from '../../vc-util/Dom/addEventListener'; import addEventListener from '../../vc-util/Dom/addEventListener';
@ -49,6 +53,11 @@ export const previewProps = {
...dialogPropTypes(), ...dialogPropTypes(),
src: String, src: String,
alt: String, alt: String,
fallback: String,
placeholder: {
type: Object as PropType<VNode | boolean>,
default: () => true,
},
rootClassName: String, rootClassName: String,
icons: { icons: {
type: Object as PropType<PreviewProps['icons']>, type: Object as PropType<PreviewProps['icons']>,
@ -65,7 +74,20 @@ const Preview = defineComponent({
const { rotateLeft, rotateRight, zoomIn, zoomOut, close, left, right, flipX, flipY } = reactive( const { rotateLeft, rotateRight, zoomIn, zoomOut, close, left, right, flipX, flipY } = reactive(
props.icons, props.icons,
); );
// placeholder
const isCustomPlaceholder = computed(() => isVNode(props.placeholder));
const isDefaultPlaceholder = computed(() => {
return props.placeholder === true;
});
const hasPlaceholder = computed(() => isCustomPlaceholder.value || isDefaultPlaceholder.value);
const status: Ref<ImageStatus> = ref(hasPlaceholder.value ? 'loading' : 'normal');
watch(
() => props.src,
() => {
status.value = 'loading';
},
);
const scale = shallowRef(1); const scale = shallowRef(1);
const rotate = shallowRef(0); const rotate = shallowRef(0);
const flip = reactive({ x: 1, y: 1 }); const flip = reactive({ x: 1, y: 1 });
@ -386,6 +408,11 @@ const Preview = defineComponent({
<img <img
onMousedown={onMouseDown} onMousedown={onMouseDown}
onDblclick={onDoubleClick} onDblclick={onDoubleClick}
onLoad={() => {
nextTick(() => {
status.value = 'normal';
});
}}
ref={imgRef} ref={imgRef}
class={`${props.prefixCls}-img`} class={`${props.prefixCls}-img`}
src={combinationSrc.value} src={combinationSrc.value}
@ -396,6 +423,12 @@ const Preview = defineComponent({
}deg)`, }deg)`,
}} }}
/> />
{isDefaultPlaceholder.value && status.value === 'loading' && (
<Spin size="large" class={`${props.prefixCls}-img-placeholder`}></Spin>
)}
{isCustomPlaceholder.value && status.value === 'loading' && (
<div class={`${props.prefixCls}-img-placeholder`}>{props.placeholder}</div>
)}
</div> </div>
{showLeftOrRightSwitches.value && ( {showLeftOrRightSwitches.value && (
<div <div