feat: add image (#3235)

* feat: 🆕 image组件 - 添加样式

* feat: 🆕 增加 AImage 组件

* feat: 🆕 参考 ant-design 增加 相关 Image 组件的less定义

* feat: 🆕 Image placeholder 的修改

* fix: 🐞 去除 onPreviewClose 相关

* test: 🧪 image width/height 以及相关测试

* fix: 🐞 fix lint no-setup-props-destructure

* test: 🧪 image test snap file

* refactor: 💡 去掉多加了的文件,重构文件以使逻辑清晰

* feat: 🆕 rc-image 相关内容 列入 vc-image 文件夹中

* feat: 🆕 antd 4.9.1 增加 image-preview-group

* feat: 🆕 add ImagePropsType

* feat: udpate image components

* feat: update image

* feat: update image

Co-authored-by: tanjinzhou <415800467@qq.com>
This commit is contained in:
Zou Jian 2020-12-18 18:02:51 +08:00 committed by GitHub
parent c6b189b583
commit 5913cf9c5c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1521 additions and 2 deletions

@ -1 +1 @@
Subproject commit ab88ac19de8ce0d3c8a559ad7bcd5fc04532c184 Subproject commit db458a2276cd9156a7824f4e876de5702efd9ff7

View File

@ -0,0 +1,25 @@
import PreviewGroup from '../vc-image/src/PreviewGroup';
import { defineComponent, inject } from 'vue';
import { defaultConfigProvider } from '../config-provider';
import PropTypes from '../_util/vue-types';
const InternalPreviewGroup = defineComponent({
name: 'AImagePreviewGroup',
inheritAttrs: false,
props: { previewPrefixCls: PropTypes.string },
setup(props, { attrs, slots }) {
const configProvider = inject('configProvider', defaultConfigProvider);
return () => {
const { getPrefixCls } = configProvider;
const prefixCls = getPrefixCls('image-preview', props.previewPrefixCls);
return (
<PreviewGroup
previewPrefixCls={prefixCls}
{...{ ...attrs, ...props }}
v-slots={slots}
></PreviewGroup>
);
};
},
});
export default InternalPreviewGroup;

View File

@ -0,0 +1,3 @@
import demoTest from '../../../tests/shared/demoTest';
demoTest('image');

View File

@ -0,0 +1,32 @@
import Image from '..';
import mountTest from '../../../tests/shared/mountTest';
import { mount } from '@vue/test-utils';
describe('Image', () => {
mountTest(Image);
it('image size', () => {
const wrapper = mount({
render() {
return (
<Image
width="200px"
src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png"
/>
);
},
});
expect(wrapper.find('.ant-image').element.style.width).toBe('200px');
});
it('image size number', () => {
const wrapper = mount({
render() {
return (
<Image
width={200}
src="https://zos.alipayobjects.com/rmsportal/jkjgkEfvpUPVyRjUImniVslZfWPnJuuZ.png"
/>
);
},
});
expect(wrapper.find('.ant-image').element.style.width).toBe('200px');
});
});

View File

@ -0,0 +1,35 @@
import { App, defineComponent, inject, Plugin } from 'vue';
import { defaultConfigProvider } from '../config-provider';
import ImageInternal from '../vc-image';
import { ImageProps, ImagePropsType } from '../vc-image/src/Image';
import PreviewGroup from './PreviewGroup';
const Image = defineComponent({
name: 'AImage',
inheritAttrs: false,
props: ImageProps,
setup(props, ctx) {
const { slots, attrs } = ctx;
const configProvider = inject('configProvider', defaultConfigProvider);
return () => {
const { getPrefixCls } = configProvider;
const prefixCls = getPrefixCls('image', props.prefixCls);
return <ImageInternal {...{ ...attrs, ...props, prefixCls }} v-slots={slots}></ImageInternal>;
};
},
});
export { ImageProps, ImagePropsType };
Image.PreviewGroup = PreviewGroup;
Image.install = function(app: App) {
app.component(Image.name, Image);
app.component(Image.PreviewGroup.name, Image.PreviewGroup);
return app;
};
export default Image as typeof Image &
Plugin & {
readonly PreviewGroup: typeof PreviewGroup;
};

View File

@ -0,0 +1,141 @@
@import '../../style/themes/index';
@import '../../style/mixins/index';
@image-prefix-cls: ~'@{ant-prefix}-image';
@image-preview-prefix-cls: ~'@{image-prefix-cls}-preview';
.@{image-prefix-cls} {
position: relative;
display: inline-block;
&-img {
width: 100%;
height: auto;
&-placeholder {
background-color: @image-bg;
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cGF0aCBkPSJNMTQuNSAyLjVoLTEzQS41LjUgMCAwIDAgMSAzdjEwYS41LjUgMCAwIDAgLjUuNWgxM2EuNS41IDAgMCAwIC41LS41VjNhLjUuNSAwIDAgMC0uNS0uNXpNNS4yODEgNC43NWExIDEgMCAwIDEgMCAyIDEgMSAwIDAgMSAwLTJ6bTguMDMgNi44M2EuMTI3LjEyNyAwIDAgMS0uMDgxLjAzSDIuNzY5YS4xMjUuMTI1IDAgMCAxLS4wOTYtLjIwN2wyLjY2MS0zLjE1NmEuMTI2LjEyNiAwIDAgMSAuMTc3LS4wMTZsLjAxNi4wMTZMNy4wOCAxMC4wOWwyLjQ3LTIuOTNhLjEyNi4xMjYgMCAwIDEgLjE3Ny0uMDE2bC4wMTUuMDE2IDMuNTg4IDQuMjQ0YS4xMjcuMTI3IDAgMCAxLS4wMi4xNzV6IiBmaWxsPSIjOEM4QzhDIiBmaWxsLXJ1bGU9Im5vbnplcm8iLz48L3N2Zz4=);
background-repeat: no-repeat;
background-position: center center;
background-size: 30%;
}
}
&-placeholder {
.box;
}
&-preview {
.modal-mask;
height: 100%;
text-align: center;
&-body {
.box;
overflow: hidden;
}
&-img {
max-width: 100%;
max-height: 100%;
vertical-align: middle;
transform: scale3d(1, 1, 1);
cursor: grab;
transition: transform 0.3s @ease-out 0s;
user-select: none;
pointer-events: auto;
&-wrapper {
.box;
transition: transform 0.3s @ease-out 0s;
&::before {
display: inline-block;
width: 1px;
height: 50%;
margin-right: -1px;
content: '';
}
}
}
&-moving {
.@{image-prefix-cls}-preview-img {
cursor: grabbing;
&-wrapper {
transition-duration: 0s;
}
}
}
&-wrap {
z-index: @zindex-image;
}
&-operations {
.reset-component;
position: absolute;
top: 0;
right: 0;
z-index: 1;
display: flex;
flex-direction: row-reverse;
align-items: center;
width: 100%;
color: @image-preview-operation-color;
list-style: none;
background: fade(@modal-mask-bg, 10%);
pointer-events: auto;
&-operation {
margin-left: @control-padding-horizontal;
padding: @control-padding-horizontal;
cursor: pointer;
&-disabled {
color: @image-preview-operation-disabled-color;
pointer-events: none;
}
&:last-of-type {
margin-left: 0;
}
}
&-icon {
font-size: @image-preview-operation-size;
}
}
&-switch-left,
&-switch-right {
position: absolute;
top: 50%;
right: 10px;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-top: -22px;
color: @image-preview-operation-color;
background: fade(@modal-mask-bg, 10%);
border-radius: 50%;
cursor: pointer;
pointer-events: auto;
&-disabled {
color: @image-preview-operation-disabled-color;
cursor: not-allowed;
> .anticon {
cursor: not-allowed;
}
}
> .anticon {
font-size: 18px;
}
}
&-switch-left {
left: 10px;
}
&-switch-right {
right: 10px;
}
}
}

View File

@ -0,0 +1,2 @@
import '../../style/index.less';
import './index.less';

View File

@ -133,7 +133,7 @@ import { default as Drawer } from './drawer';
import { default as Skeleton } from './skeleton'; import { default as Skeleton } from './skeleton';
import { default as Comment } from './comment'; import { default as Comment } from './comment';
import { default as Image } from './image';
// import { default as ColorPicker } from './color-picker'; // import { default as ColorPicker } from './color-picker';
import { default as ConfigProvider } from './config-provider'; import { default as ConfigProvider } from './config-provider';
@ -209,6 +209,7 @@ const components = [
Descriptions, Descriptions,
PageHeader, PageHeader,
Space, Space,
Image,
]; ];
const install = function(app: App) { const install = function(app: App) {
@ -296,6 +297,7 @@ export {
Descriptions, Descriptions,
PageHeader, PageHeader,
Space, Space,
Image,
}; };
export default { export default {

View File

@ -60,4 +60,5 @@ import './descriptions/style';
import './page-header/style'; import './page-header/style';
import './form/style'; import './form/style';
import './space/style'; import './space/style';
import './image/style';
// import './color-picker/style'; // import './color-picker/style';

View File

@ -0,0 +1,7 @@
.box(@position: absolute) {
position: @position;
top: 0;
right: 0;
bottom: 0;
left: 0;
}

View File

@ -8,3 +8,5 @@
@import 'reset'; @import 'reset';
@import 'operation-unit'; @import 'operation-unit';
@import 'typography'; @import 'typography';
@import 'box';
@import 'modal-mask';

View File

@ -0,0 +1,31 @@
@import 'box';
.modal-mask() {
pointer-events: none;
&.zoom-enter,
&.zoom-appear {
transform: none; // reset scale avoid mousePosition bug
opacity: 0;
animation-duration: @animation-duration-slow;
user-select: none; // https://github.com/ant-design/ant-design/issues/11777
}
&-mask {
.box(fixed);
z-index: @zindex-modal-mask;
height: 100%;
background-color: @modal-mask-bg;
&-hidden {
display: none;
}
}
&-wrap {
.box(fixed);
overflow: auto;
outline: 0;
-webkit-overflow-scrolling: touch;
}
}

View File

@ -288,6 +288,7 @@
@zindex-dropdown: 1050; @zindex-dropdown: 1050;
@zindex-picker: 1050; @zindex-picker: 1050;
@zindex-tooltip: 1060; @zindex-tooltip: 1060;
@zindex-image: 1080;
// Animation // Animation
@animation-duration-slow: 0.3s; // Modal @animation-duration-slow: 0.3s; // Modal
@ -720,3 +721,13 @@
@typography-title-font-weight: 600; @typography-title-font-weight: 600;
@typography-title-margin-top: 1.2em; @typography-title-margin-top: 1.2em;
@typography-title-margin-bottom: 0.5em; @typography-title-margin-bottom: 0.5em;
// Image
// ---
@image-size-base: 48px;
@image-font-size-base: 24px;
@image-bg: #f5f5f5;
@image-color: #fff;
@image-preview-operation-size: 18px;
@image-preview-operation-color: @text-color-dark;
@image-preview-operation-disabled-color: fade(@image-preview-operation-color, 45%);

View File

@ -0,0 +1,313 @@
@import '../../style/themes/index';
@import '../../style/mixins/index';
@prefixCls: ~'@{ant-prefix}-image';
@zindex-preview-mask: 1000;
@preview-mask-bg: fade(#000, 40%);
@text-color: #bbb;
@text-color-disabled: darken(@text-color, 30%);
@background-color: #f3f3f3;
.reset() {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.box() {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.@{prefixCls} {
display: inline-block;
position: relative;
&-img {
width: 100%;
height: auto;
&-placeholder {
background-color: @background-color;
background-repeat: no-repeat;
background-position: center center;
background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMjhweCIgaGVpZ2h0PSIyMnB4IiB2aWV3Qm94PSIwIDAgMjggMjIiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDU1LjIgKDc4MTgxKSAtIGh0dHBzOi8vc2tldGNoYXBwLmNvbSAtLT4KICAgIDx0aXRsZT5pbWFnZS1maWxs5aSH5Lu9PC90aXRsZT4KICAgIDxkZXNjPkNyZWF0ZWQgd2l0aCBTa2V0Y2guPC9kZXNjPgogICAgPGcgaWQ9Iuafpeeci+WbvueJh+S8mOWMljQuMCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9IuWKoOi9veWbvueJhyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTU3Mi4wMDAwMDAsIC01MDYuMDAwMDAwKSI+CiAgICAgICAgICAgIDxnIGlkPSJpbWFnZS1maWxs5aSH5Lu9IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg1NzAuMDAwMDAwLCA1MDEuMDAwMDAwKSI+CiAgICAgICAgICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlIiBmaWxsPSIjMDAwMDAwIiBvcGFjaXR5PSIwIiB4PSIwIiB5PSIwIiB3aWR0aD0iMzIiIGhlaWdodD0iMzIiPjwvcmVjdD4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0yOSw1IEwzLDUgQzIuNDQ2ODc1LDUgMiw1LjQ0Njg3NSAyLDYgTDIsMjYgQzIsMjYuNTUzMTI1IDIuNDQ2ODc1LDI3IDMsMjcgTDI5LDI3IEMyOS41NTMxMjUsMjcgMzAsMjYuNTUzMTI1IDMwLDI2IEwzMCw2IEMzMCw1LjQ0Njg3NSAyOS41NTMxMjUsNSAyOSw1IFogTTEwLjU2MjUsOS41IEMxMS42NjU2MjUsOS41IDEyLjU2MjUsMTAuMzk2ODc1IDEyLjU2MjUsMTEuNSBDMTIuNTYyNSwxMi42MDMxMjUgMTEuNjY1NjI1LDEzLjUgMTAuNTYyNSwxMy41IEM5LjQ1OTM3NSwxMy41IDguNTYyNSwxMi42MDMxMjUgOC41NjI1LDExLjUgQzguNTYyNSwxMC4zOTY4NzUgOS40NTkzNzUsOS41IDEwLjU2MjUsOS41IFogTTI2LjYyMTg3NSwyMy4xNTkzNzUgQzI2LjU3ODEyNSwyMy4xOTY4NzUgMjYuNTE4NzUsMjMuMjE4NzUgMjYuNDU5Mzc1LDIzLjIxODc1IEw1LjUzNzUsMjMuMjE4NzUgQzUuNCwyMy4yMTg3NSA1LjI4NzUsMjMuMTA2MjUgNS4yODc1LDIyLjk2ODc1IEM1LjI4NzUsMjIuOTA5Mzc1IDUuMzA5Mzc1LDIyLjg1MzEyNSA1LjM0Njg3NSwyMi44MDYyNSBMMTAuNjY4NzUsMTYuNDkzNzUgQzEwLjc1NjI1LDE2LjM4NzUgMTAuOTE1NjI1LDE2LjM3NSAxMS4wMjE4NzUsMTYuNDYyNSBDMTEuMDMxMjUsMTYuNDcxODc1IDExLjA0Mzc1LDE2LjQ4MTI1IDExLjA1MzEyNSwxNi40OTM3NSBMMTQuMTU5Mzc1LDIwLjE4MTI1IEwxOS4xLDE0LjMyMTg3NSBDMTkuMTg3NSwxNC4yMTU2MjUgMTkuMzQ2ODc1LDE0LjIwMzEyNSAxOS40NTMxMjUsMTQuMjkwNjI1IEMxOS40NjI1LDE0LjMgMTkuNDc1LDE0LjMwOTM3NSAxOS40ODQzNzUsMTQuMzIxODc1IEwyNi42NTkzNzUsMjIuODA5Mzc1IEMyNi43NDA2MjUsMjIuOTEyNSAyNi43MjgxMjUsMjMuMDcxODc1IDI2LjYyMTg3NSwyMy4xNTkzNzUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjRThFOEU4Ij48L3BhdGg+CiAgICAgICAgICAgIDwvZz4KICAgICAgICA8L2c+CiAgICA8L2c+Cjwvc3ZnPg==);
}
}
&-placeholder {
.box;
}
&-preview {
text-align: center;
height: 100%;
pointer-events: none;
&-body {
.box;
overflow: hidden;
}
&.zoom-enter,
&.zoom-appear {
transform: none;
opacity: 0;
animation-duration: 0.3s;
}
&-mask {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: @zindex-preview-mask;
height: 100%;
background-color: @preview-mask-bg;
filter: ~'alpha(opacity=50)';
&-hidden {
display: none;
}
}
&-img {
cursor: grab;
transform: scale3d(1, 1, 1);
transition: transform 0.3s cubic-bezier(0, 0, 0.25, 1) 0s;
user-select: none;
vertical-align: middle;
max-width: 100%;
max-height: 100%;
pointer-events: auto;
&-wrapper {
.box;
transition: transform 0.3s cubic-bezier(0, 0, 0.25, 1) 0s;
&::before {
content: '';
display: inline-block;
height: 50%;
width: 1px;
margin-right: -1px;
}
}
}
&-moving {
.@{prefixCls}-preview-img {
cursor: grabbing;
&-wrapper {
transition-duration: 0s;
}
}
}
&-wrap {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: @zindex-preview-mask;
overflow: auto;
outline: 0;
-webkit-overflow-scrolling: touch;
}
&-operations {
.reset;
pointer-events: auto;
list-style: none;
position: absolute;
display: flex;
top: 0;
right: 0;
width: 100%;
align-items: center;
flex-direction: row-reverse;
z-index: 1;
color: @text-color;
background: fade(@preview-mask-bg, 45%);
&-operation {
padding: 10px;
cursor: pointer;
margin-left: 10px;
&-disabled {
pointer-events: none;
color: @text-color-disabled;
}
&:last-of-type {
margin-left: 0;
}
}
&-icon {
font-size: 18px;
}
}
&-switch-left {
position: absolute;
left: 10px;
top: 50%;
width: 44px;
height: 44px;
margin-top: -22px;
background: fade(@text-color, 45%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
pointer-events: auto;
color: @text-color;
&-disabled {
background: fade(@text-color, 30%);
color: @text-color-disabled;
cursor: not-allowed;
> .anticon {
cursor: not-allowed;
}
}
> .anticon {
font-size: 24px;
}
}
&-switch-right {
position: absolute;
right: 10px;
top: 50%;
width: 44px;
height: 44px;
margin-top: -22px;
background: fade(@text-color, 45%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
pointer-events: auto;
color: @text-color;
&-disabled {
background: fade(@text-color, 20%);
color: @text-color-disabled;
cursor: not-allowed;
> .anticon {
cursor: not-allowed;
}
}
> .anticon {
font-size: 24px;
}
}
}
}
.fade-enter,
.fade-appear {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
opacity: 0;
animation-timing-function: linear;
}
.fade-leave {
animation-duration: 0.2s;
animation-fill-mode: both;
animation-play-state: paused;
animation-timing-function: linear;
}
.fade-enter.fade-enter-active,
.fade-appear.fade-appear-active {
animation-name: rcImageFadeIn;
animation-play-state: running;
}
.fade-leave.fade-leave-active {
animation-name: rcImageFadeOut;
animation-play-state: running;
pointer-events: none;
}
@keyframes rcImageFadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes rcImageFadeOut {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.zoom-enter,
.zoom-appear {
-webkit-animation-duration: 0.2s;
animation-duration: 0.2s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation-play-state: paused;
animation-play-state: paused;
-webkit-transform: scale(0);
transform: scale(0);
opacity: 0;
-webkit-animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1);
}
.zoom-leave {
-webkit-animation-duration: 0.2s;
animation-duration: 0.2s;
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation-play-state: paused;
animation-play-state: paused;
-webkit-animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
animation-timing-function: cubic-bezier(0.78, 0.14, 0.15, 0.86);
}
.zoom-enter.zoom-enter-active,
.zoom-appear.zoom-appear-active {
-webkit-animation-name: rcImageZoomIn;
animation-name: rcImageZoomIn;
-webkit-animation-play-state: running;
animation-play-state: running;
}
.zoom-leave.zoom-leave-active {
-webkit-animation-name: rcImageZoomOut;
animation-name: rcImageZoomOut;
-webkit-animation-play-state: running;
animation-play-state: running;
pointer-events: none;
}
@keyframes rcImageZoomIn {
0% {
-webkit-transform: scale(0.2);
transform: scale(0.2);
opacity: 0;
}
100% {
-webkit-transform: scale(1);
transform: scale(1);
opacity: 1;
}
}
@keyframes rcImageZoomOut {
0% {
-webkit-transform: scale(1);
transform: scale(1);
}
100% {
-webkit-transform: scale(0.2);
transform: scale(0.2);
opacity: 0;
}
}

View File

@ -0,0 +1,4 @@
// based on rc-image 4.3.2
import Image from './src/Image';
export * from './src/Image';
export default Image;

View File

@ -0,0 +1,291 @@
import {
ImgHTMLAttributes,
CSSProperties,
ref,
watch,
defineComponent,
computed,
onMounted,
} from 'vue';
import { isNumber } from 'lodash-es';
import BaseMixin from '../../_util/BaseMixin';
import cn from '../../_util/classNames';
import PropTypes from '../../_util/vue-types';
import { getOffset } from '../../vc-util/Dom/css';
import Preview, { MouseEventHandler } from './Preview';
import PreviewGroup, { context } from './PreviewGroup';
export type GetContainer = string | HTMLElement | (() => HTMLElement);
export interface ImagePreviewType {
visible?: boolean;
onVisibleChange?: (value: boolean, prevValue: boolean) => void;
getContainer?: GetContainer | false;
}
export interface ImagePropsType extends Omit<ImgHTMLAttributes, 'placeholder' | 'onClick'> {
// Original
src?: string;
wrapperClassName?: string;
wrapperStyle?: CSSProperties;
prefixCls?: string;
previewPrefixCls?: string;
placeholder?: boolean;
fallback?: string;
preview?: boolean | ImagePreviewType;
}
export const ImageProps = {
src: PropTypes.string,
wrapperClassName: PropTypes.string,
wrapperStyle: PropTypes.style,
prefixCls: PropTypes.string,
previewPrefixCls: PropTypes.string,
placeholder: PropTypes.VNodeChild,
fallback: PropTypes.string,
preview: PropTypes.oneOfType([PropTypes.looseBool, PropTypes.shape<ImagePreviewType>({})]).def(
true,
),
};
type ImageStatus = 'normal' | 'error' | 'loading';
const mergeDefaultValue = <T extends object>(obj: T, defaultValues: object): T => {
const res = { ...obj };
Object.keys(defaultValues).forEach(key => {
if (obj[key] === undefined) {
res[key] = defaultValues[key];
}
});
return res;
};
let uuid = 0;
const ImageInternal = defineComponent({
name: 'Image',
mixins: [BaseMixin],
inheritAttrs: false,
props: ImageProps,
emits: ['click'],
setup(props, { attrs, slots, emit }) {
const prefixCls = computed(() => props.prefixCls);
const previewPrefixCls = computed(() => `${prefixCls.value}-preview`);
const preview = computed(() => {
const defaultValues = {
visible: undefined,
onVisibleChange: () => {},
getContainer: undefined,
};
return typeof props.preview === 'object'
? mergeDefaultValue(props.preview, defaultValues)
: defaultValues;
});
const isCustomPlaceholder = computed(
() => (props.placeholder && props.placeholder !== true) || slots.placeholder,
);
const previewVisible = computed(() => preview.value.visible);
const onPreviewVisibleChange = computed(() => preview.value.onVisibleChange);
const getPreviewContainer = computed(() => preview.value.getContainer);
const isControlled = computed(() => previewVisible.value !== undefined);
const isShowPreview = ref(!!previewVisible.value);
watch(previewVisible, () => {
isShowPreview.value = !!previewVisible.value;
});
watch(isShowPreview, (val, preVal) => {
onPreviewVisibleChange.value(val, preVal);
});
const status = ref<ImageStatus>(isCustomPlaceholder.value ? 'loading' : 'normal');
watch(
() => props.src,
() => {
status.value = isCustomPlaceholder.value ? 'loading' : 'normal';
},
);
const mousePosition = ref<null | { x: number; y: number }>(null);
const isError = computed(() => status.value === 'error');
const groupContext = context.inject();
const {
isPreviewGroup,
setCurrent,
setShowPreview: setGroupShowPreview,
setMousePosition: setGroupMousePosition,
registerImage,
} = groupContext;
const currentId = ref(uuid++);
const canPreview = computed(() => props.preview && !isError.value);
const onLoad = () => {
status.value = 'normal';
};
const onError = () => {
status.value = 'error';
};
const onPreview: MouseEventHandler = e => {
if (!isControlled.value) {
const { left, top } = getOffset(e.target);
if (isPreviewGroup.value) {
setCurrent(currentId.value);
setGroupMousePosition({
x: left,
y: top,
});
} else {
mousePosition.value = {
x: left,
y: top,
};
}
}
if (isPreviewGroup.value) {
setGroupShowPreview(true);
} else {
isShowPreview.value = true;
}
emit('click', e);
};
const onPreviewClose = () => {
isShowPreview.value = false;
if (!isControlled.value) {
mousePosition.value = null;
}
};
const img = ref<HTMLImageElement>(null);
watch(
() => img,
() => {
if (status.value !== 'loading') return;
if (img.value.complete && (img.value.naturalWidth || img.value.naturalHeight)) {
onLoad();
}
},
);
let unRegister = () => {};
onMounted(() => {
watch(
[() => props.src, canPreview],
() => {
unRegister();
if (!isPreviewGroup.value) {
return () => {};
}
unRegister = registerImage(currentId.value, props.src);
if (!canPreview.value) {
unRegister();
}
},
{ flush: 'post', immediate: true },
);
});
const toSizePx = (l: number | string) => {
if (isNumber(l)) return l + 'px';
return l;
};
return () => {
const {
prefixCls,
wrapperClassName,
fallback,
src,
preview,
placeholder,
wrapperStyle,
} = props;
const {
width,
height,
crossorigin,
decoding,
alt,
sizes,
srcset,
usemap,
class: cls,
style,
} = attrs as ImgHTMLAttributes;
const wrappperClass = cn(prefixCls, wrapperClassName, {
[`${prefixCls}-error`]: isError.value,
});
const mergedSrc = isError.value && fallback ? fallback : src;
const previewMask = slots.previewMask && slots.previewMask();
const imgCommonProps = {
crossorigin,
decoding,
alt,
sizes,
srcset,
usemap,
class: cn(
`${prefixCls}-img`,
{
[`${prefixCls}-img-placeholder`]: placeholder === true,
},
cls,
),
style: {
height,
...(style as CSSProperties),
},
};
return (
<>
<div
class={wrappperClass}
onClick={
preview && !isError.value
? onPreview
: e => {
emit('click', e);
}
}
style={{
width: toSizePx(width),
height: toSizePx(height),
...wrapperStyle,
}}
>
<img
{...imgCommonProps}
{...(isError.value && fallback
? {
src: fallback,
}
: { onLoad, onError, src })}
ref={img}
/>
{status.value === 'loading' && (
<div aria-hidden="true" class={`${prefixCls}-placeholder`}>
{placeholder || (slots.placeholder && slots.placeholder())}
</div>
)}
{/* Preview Click Mask */}
{previewMask && canPreview.value && (
<div class={`${prefixCls}-mask`}>{previewMask}</div>
)}
</div>
{!isPreviewGroup.value && canPreview.value && (
<Preview
aria-hidden={!isShowPreview.value}
visible={isShowPreview.value}
prefixCls={previewPrefixCls.value}
onClose={onPreviewClose}
mousePosition={mousePosition.value}
src={mergedSrc}
alt={alt}
getContainer={getPreviewContainer}
/>
)}
</>
);
};
},
});
ImageInternal.PreviewGroup = PreviewGroup;
export default ImageInternal as typeof ImageInternal & {
readonly PreviewGroup: typeof PreviewGroup;
};

View File

@ -0,0 +1,314 @@
import { computed, defineComponent, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import {
RotateLeftOutlined,
RotateRightOutlined,
ZoomInOutlined,
ZoomOutOutlined,
CloseOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons-vue';
import classnames from '../../_util/classNames';
import PropTypes from '../../_util/vue-types';
import Dialog from '../../vc-dialog';
import getIDialogPropTypes from '../../vc-dialog/IDialogPropTypes';
import { getOffset } from '../../vc-util/Dom/css';
import addEventListener from '../../vc-util/Dom/addEventListener';
import { warning } from '../../vc-util/warning';
import useFrameSetState from './hooks/useFrameSetState';
import getFixScaleEleTransPosition from './getFixScaleEleTransPosition';
import { context } from './PreviewGroup';
const IDialogPropTypes = getIDialogPropTypes();
export type MouseEventHandler = (payload: MouseEvent) => void;
export interface PreviewProps extends Omit<typeof IDialogPropTypes, 'onClose'> {
onClose?: (e: Element) => void;
src?: string;
alt?: string;
}
const initialPosition = {
x: 0,
y: 0,
};
const PreviewType = {
src: PropTypes.string,
alt: PropTypes.string,
...IDialogPropTypes,
};
const Preview = defineComponent({
name: 'Preview',
inheritAttrs: false,
props: PreviewType,
emits: ['close', 'afterClose'],
setup(props, { emit, attrs }) {
const scale = ref(1);
const rotate = ref(0);
const [position, setPosition] = useFrameSetState<{
x: number;
y: number;
}>(initialPosition);
const onClose = () => emit('close');
const imgRef = ref<HTMLImageElement>();
const originPositionRef = reactive<{
originX: number;
originY: number;
deltaX: number;
deltaY: number;
}>({
originX: 0,
originY: 0,
deltaX: 0,
deltaY: 0,
});
const isMoving = ref(false);
const groupContext = context.inject();
const { previewUrls, current, isPreviewGroup, setCurrent } = groupContext;
const previewGroupCount = computed(() => Object.keys(previewUrls).length);
const previewUrlsKeys = computed(() => Object.keys(previewUrls));
const currentPreviewIndex = computed(() =>
previewUrlsKeys.value.indexOf(String(current.value)),
);
const combinationSrc = computed(() =>
isPreviewGroup.value ? previewUrls[current.value] : props.src,
);
const showLeftOrRightSwitches = computed(
() => isPreviewGroup.value && previewGroupCount.value > 1,
);
const onAfterClose = () => {
scale.value = 1;
rotate.value = 0;
setPosition(initialPosition);
};
const onZoomIn = () => {
scale.value++;
setPosition(initialPosition);
};
const onZoomOut = () => {
if (scale.value > 1) {
scale.value--;
}
setPosition(initialPosition);
};
const onRotateRight = () => {
rotate.value += 90;
};
const onRotateLeft = () => {
rotate.value -= 90;
};
const onSwitchLeft: MouseEventHandler = event => {
event.preventDefault();
// Without this mask close will abnormal
event.stopPropagation();
if (currentPreviewIndex.value > 0) {
setCurrent(previewUrlsKeys.value[String(currentPreviewIndex.value - 1)]);
}
};
const onSwitchRight: MouseEventHandler = event => {
event.preventDefault();
// Without this mask close will abnormal
event.stopPropagation();
if (currentPreviewIndex.value < previewGroupCount.value - 1) {
setCurrent(previewUrlsKeys.value[String(currentPreviewIndex.value + 1)]);
}
};
const wrapClassName = classnames({
[`${props.prefixCls}-moving`]: isMoving.value,
});
const toolClassName = `${props.prefixCls}-operations-operation`;
const iconClassName = `${props.prefixCls}-operations-icon`;
const tools = [
{
icon: CloseOutlined,
onClick: onClose,
type: 'close',
},
{
icon: ZoomInOutlined,
onClick: onZoomIn,
type: 'zoomIn',
},
{
icon: ZoomOutOutlined,
onClick: onZoomOut,
type: 'zoomOut',
disabled: computed(() => scale.value === 1),
},
{
icon: RotateRightOutlined,
onClick: onRotateRight,
type: 'rotateRight',
},
{
icon: RotateLeftOutlined,
onClick: onRotateLeft,
type: 'rotateLeft',
},
];
const onMouseUp: MouseEventHandler = () => {
if (props.visible && isMoving.value) {
const width = imgRef.value.offsetWidth * scale.value;
const height = imgRef.value.offsetHeight * scale.value;
const { left, top } = getOffset(imgRef.value);
const isRotate = rotate.value % 180 !== 0;
isMoving.value = false;
const fixState = getFixScaleEleTransPosition(
isRotate ? height : width,
isRotate ? width : height,
left,
top,
);
if (fixState) {
setPosition({ ...fixState });
}
}
};
const onMouseDown: MouseEventHandler = event => {
event.preventDefault();
// Without this mask close will abnormal
event.stopPropagation();
originPositionRef.deltaX = event.pageX - position.x;
originPositionRef.deltaY = event.pageY - position.y;
originPositionRef.originX = position.x;
originPositionRef.originY = position.y;
isMoving.value = true;
};
const onMouseMove: MouseEventHandler = event => {
if (props.visible && isMoving.value) {
setPosition({
x: event.pageX - originPositionRef.deltaX,
y: event.pageY - originPositionRef.deltaY,
});
}
};
let removeListeners = () => {};
onMounted(() => {
watch(
[() => props.visible, isMoving],
() => {
removeListeners();
let onTopMouseUpListener: { remove: any };
let onTopMouseMoveListener: { remove: any };
const onMouseUpListener = addEventListener(window, 'mouseup', onMouseUp, false);
const onMouseMoveListener = addEventListener(window, 'mousemove', onMouseMove, false);
try {
// Resolve if in iframe lost event
/* istanbul ignore next */
if (window.top !== window.self) {
onTopMouseUpListener = addEventListener(window.top, 'mouseup', onMouseUp, false);
onTopMouseMoveListener = addEventListener(
window.top,
'mousemove',
onMouseMove,
false,
);
}
} catch (error) {
/* istanbul ignore next */
warning(false, `[vc-image] ${error}`);
}
removeListeners = () => {
onMouseUpListener.remove();
onMouseMoveListener.remove();
/* istanbul ignore next */
if (onTopMouseUpListener) onTopMouseUpListener.remove();
/* istanbul ignore next */
if (onTopMouseMoveListener) onTopMouseMoveListener.remove();
};
},
{ flush: 'post', immediate: true },
);
});
onUnmounted(() => {
removeListeners();
});
return () => (
<Dialog
{...attrs}
transitionName="zoom"
maskTransitionName="fade"
closable={false}
keyboard
prefixCls={props.prefixCls}
onClose={onClose}
afterClose={onAfterClose}
visible={props.visible}
wrapClassName={wrapClassName}
>
<ul class={`${props.prefixCls}-operations`}>
{tools.map(({ icon: IconType, onClick, type, disabled }) => (
<li
class={classnames(toolClassName, {
[`${props.prefixCls}-operations-operation-disabled`]: disabled && disabled?.value,
})}
onClick={onClick}
key={type}
>
<IconType class={iconClassName} />
</li>
))}
</ul>
<div
class={`${props.prefixCls}-img-wrapper`}
style={{
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
}}
>
<img
onMousedown={onMouseDown}
ref={imgRef}
class={`${props.prefixCls}-img`}
src={combinationSrc.value}
alt={props.alt}
style={{
transform: `scale3d(${scale.value}, ${scale.value}, 1) rotate(${rotate.value}deg)`,
}}
/>
</div>
{showLeftOrRightSwitches.value && (
<div
class={classnames(`${props.prefixCls}-switch-left`, {
[`${props.prefixCls}-switch-left-disabled`]: currentPreviewIndex.value <= 0,
})}
onClick={onSwitchLeft}
>
<LeftOutlined />
</div>
)}
{showLeftOrRightSwitches.value && (
<div
class={classnames(`${props.prefixCls}-switch-right`, {
[`${props.prefixCls}-switch-right-disabled`]:
currentPreviewIndex.value >= previewGroupCount.value - 1,
})}
onClick={onSwitchRight}
>
<RightOutlined />
</div>
)}
</Dialog>
);
},
});
export default Preview;

View File

@ -0,0 +1,97 @@
import { ref, provide, defineComponent, inject, Ref, reactive } from 'vue';
import Preview from './Preview';
export interface GroupConsumerProps {
previewPrefixCls?: string;
}
export interface GroupConsumerValue extends GroupConsumerProps {
isPreviewGroup?: Ref<boolean | undefined>;
previewUrls: Record<number, string>;
setPreviewUrls: (previewUrls: Record<number, string>) => void;
current: Ref<number>;
setCurrent: (current: number) => void;
setShowPreview: (isShowPreview: boolean) => void;
setMousePosition: (mousePosition: null | { x: number; y: number }) => void;
registerImage: (id: number, url: string) => () => void;
}
const previewGroupContext = Symbol('previewGroupContext');
export const context = {
provide: (val: GroupConsumerValue) => {
provide(previewGroupContext, val);
},
inject: () => {
return inject<GroupConsumerValue>(previewGroupContext, {
isPreviewGroup: ref(false),
previewUrls: reactive({}),
setPreviewUrls: () => {},
current: ref(null),
setCurrent: () => {},
setShowPreview: () => {},
setMousePosition: () => {},
registerImage: null,
});
},
};
const Group = defineComponent({
name: 'PreviewGroup',
inheritAttrs: false,
props: { previewPrefixCls: String },
setup(props, { slots }) {
const previewUrls = reactive<Record<number, string>>({});
const current = ref<number>();
const isShowPreview = ref<boolean>(false);
const mousePosition = ref<{ x: number; y: number }>(null);
const setPreviewUrls = (val: Record<number, string>) => {
Object.assign(previewUrls, val);
};
const setCurrent = (val: number) => {
current.value = val;
};
const setMousePosition = (val: null | { x: number; y: number }) => {
mousePosition.value = val;
};
const setShowPreview = (val: boolean) => {
isShowPreview.value = val;
};
const registerImage = (id: number, url: string) => {
previewUrls[id] = url;
return () => {
delete previewUrls[id];
};
};
const onPreviewClose = (e: any) => {
e?.stopPropagation();
isShowPreview.value = false;
mousePosition.value = null;
};
context.provide({
isPreviewGroup: ref(true),
previewUrls,
setPreviewUrls,
current,
setCurrent,
setShowPreview,
setMousePosition,
registerImage,
});
return () => {
return (
<>
{slots.default && slots.default()}
<Preview
ria-hidden={!isShowPreview.value}
visible={isShowPreview.value}
prefixCls={props.previewPrefixCls}
onClose={onPreviewClose}
mousePosition={mousePosition.value}
src={previewUrls[current.value]}
/>
</>
);
};
},
});
export default Group;

View File

@ -0,0 +1,61 @@
import { getClientSize } from '../../vc-util/Dom/css';
function fixPoint(key: 'x' | 'y', start: number, width: number, clientWidth: number) {
const startAddWidth = start + width;
const offsetStart = (width - clientWidth) / 2;
if (width > clientWidth) {
if (start > 0) {
return {
[key]: offsetStart,
};
}
if (start < 0 && startAddWidth < clientWidth) {
return {
[key]: -offsetStart,
};
}
} else if (start < 0 || startAddWidth > clientWidth) {
return {
[key]: start < 0 ? offsetStart : -offsetStart,
};
}
return {};
}
/**
* Fix positon x,y point when
*
* Ele width && height < client
* - Back origin
*
* - Ele width | height > clientWidth | clientHeight
* - left | top > 0 -> Back 0
* - left | top + width | height < clientWidth | clientHeight -> Back left | top + width | height === clientWidth | clientHeight
*
* Regardless of other
*/
export default function getFixScaleEleTransPosition(
width: number,
height: number,
left: number,
top: number,
): null | { x: number; y: number } {
const { width: clientWidth, height: clientHeight } = getClientSize();
let fixPos = null;
if (width <= clientWidth && height <= clientHeight) {
fixPos = {
x: 0,
y: 0,
};
} else if (width > clientWidth || height > clientHeight) {
fixPos = {
...fixPoint('x', left, width, clientWidth),
...fixPoint('y', top, height, clientHeight),
};
}
return fixPos;
}

View File

@ -0,0 +1,31 @@
import raf from '../../../_util/raf';
import { onMounted, reactive, ref } from 'vue';
type SetActionType<T> = Partial<T> | ((state: T) => Partial<T>);
export default function useFrameSetState<T extends object>(
initial: T,
): [Record<string, any>, (newState: SetActionType<T>) => void] {
const frame = ref(null);
const state = reactive({ ...initial });
const queue = ref<SetActionType<T>[]>([]);
const setFrameState = (newState: SetActionType<T>) => {
if (frame.value === null) {
queue.value = [];
frame.value = raf(() => {
let memoState: any;
queue.value.forEach((queueState: object) => {
memoState = { ...memoState, ...queueState };
});
Object.assign(state, memoState);
frame.value = null;
});
}
queue.value.push(newState as any);
};
onMounted(() => {
frame.value && raf.cancel(frame.value);
});
return [state, setFrameState];
}

View File

@ -0,0 +1,115 @@
const PIXEL_PATTERN = /margin|padding|width|height|max|min|offset/;
const removePixel = {
left: true,
top: true,
};
const floatMap = {
cssFloat: 1,
styleFloat: 1,
float: 1,
};
function getComputedStyle(node: HTMLElement) {
return node.nodeType === 1 ? node.ownerDocument.defaultView.getComputedStyle(node, null) : {};
}
function getStyleValue(node: HTMLElement, type: string, value: string) {
type = type.toLowerCase();
if (value === 'auto') {
if (type === 'height') {
return node.offsetHeight;
}
if (type === 'width') {
return node.offsetWidth;
}
}
if (!(type in removePixel)) {
removePixel[type] = PIXEL_PATTERN.test(type);
}
return removePixel[type] ? parseFloat(value) || 0 : value;
}
export function get(node: HTMLElement, name: any) {
const length = arguments.length;
const style = getComputedStyle(node);
name = floatMap[name] ? ('cssFloat' in node.style ? 'cssFloat' : 'styleFloat') : name;
return length === 1 ? style : getStyleValue(node, name, style[name] || node.style[name]);
}
export function set(node: HTMLElement, name: any, value: string | number) {
const length = arguments.length;
name = floatMap[name] ? ('cssFloat' in node.style ? 'cssFloat' : 'styleFloat') : name;
if (length === 3) {
if (typeof value === 'number' && PIXEL_PATTERN.test(name)) {
value = `${value}px`;
}
node.style[name as string] = value; // Number
return value;
}
for (const x in name) {
if (name.hasOwnProperty(x)) {
set(node, x, name[x]);
}
}
return getComputedStyle(node);
}
export function getOuterWidth(el: HTMLElement) {
if (el === document.body) {
return document.documentElement.clientWidth;
}
return el.offsetWidth;
}
export function getOuterHeight(el: HTMLElement) {
if (el === document.body) {
return window.innerHeight || document.documentElement.clientHeight;
}
return el.offsetHeight;
}
export function getDocSize() {
const width = Math.max(document.documentElement.scrollWidth, document.body.scrollWidth);
const height = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
return {
width,
height,
};
}
export function getClientSize() {
const width = document.documentElement.clientWidth;
const height = window.innerHeight || document.documentElement.clientHeight;
return {
width,
height,
};
}
export function getScroll() {
return {
scrollLeft: Math.max(document.documentElement.scrollLeft, document.body.scrollLeft),
scrollTop: Math.max(document.documentElement.scrollTop, document.body.scrollTop),
};
}
export function getOffset(node: any) {
const box = node.getBoundingClientRect();
const docElem = document.documentElement;
// < ie8 不支持 win.pageXOffset, 则使用 docElem.scrollLeft
return {
left:
box.left +
(window.pageXOffset || docElem.scrollLeft) -
(docElem.clientLeft || document.body.clientLeft || 0),
top:
box.top +
(window.pageYOffset || docElem.scrollTop) -
(docElem.clientTop || document.body.clientTop || 0),
};
}

View File

@ -60,6 +60,7 @@ Array [
"Drawer", "Drawer",
"Skeleton", "Skeleton",
"Comment", "Comment",
"Image",
"ConfigProvider", "ConfigProvider",
"Empty", "Empty",
"Result", "Result",