diff --git a/src/assets/svg/preview/p-rotate.svg b/src/assets/svg/preview/p-rotate.svg new file mode 100644 index 00000000000..5153a81690d --- /dev/null +++ b/src/assets/svg/preview/p-rotate.svg @@ -0,0 +1 @@ + diff --git a/src/assets/svg/preview/resume.svg b/src/assets/svg/preview/resume.svg new file mode 100644 index 00000000000..0e86c5f6fd3 --- /dev/null +++ b/src/assets/svg/preview/resume.svg @@ -0,0 +1 @@ + diff --git a/src/assets/svg/preview/scale.svg b/src/assets/svg/preview/scale.svg new file mode 100644 index 00000000000..1f7adaee915 --- /dev/null +++ b/src/assets/svg/preview/scale.svg @@ -0,0 +1 @@ + diff --git a/src/assets/svg/preview/unrotate.svg b/src/assets/svg/preview/unrotate.svg new file mode 100644 index 00000000000..e4708be13ed --- /dev/null +++ b/src/assets/svg/preview/unrotate.svg @@ -0,0 +1 @@ + diff --git a/src/assets/svg/preview/unscale.svg b/src/assets/svg/preview/unscale.svg new file mode 100644 index 00000000000..1359b34cd33 --- /dev/null +++ b/src/assets/svg/preview/unscale.svg @@ -0,0 +1 @@ + diff --git a/src/components/Preview/index.ts b/src/components/Preview/index.ts index 8c6b4780ea9..c0b4685ea9e 100644 --- a/src/components/Preview/index.ts +++ b/src/components/Preview/index.ts @@ -1 +1,2 @@ export { default as ImagePreview } from './src/Preview.vue'; +export { createImgPreview } from './src/functional'; diff --git a/src/components/Preview/src/functional.ts b/src/components/Preview/src/functional.ts new file mode 100644 index 00000000000..0f9eba0a834 --- /dev/null +++ b/src/components/Preview/src/functional.ts @@ -0,0 +1,22 @@ +import ImgPreview from './index'; +import { isClient } from '/@/utils/is'; + +import type { Options, Props } from './types'; + +import { createVNode, render } from 'vue'; + +let instance: any = null; +export function createImgPreview(options: Options) { + if (!isClient) return; + const { imageList, show = true, index = 0 } = options; + + const propsData: Partial = {}; + const container = document.createElement('div'); + propsData.imageList = imageList; + propsData.show = show; + propsData.index = index; + + instance = createVNode(ImgPreview, propsData); + render(instance, container); + document.body.appendChild(container); +} diff --git a/src/components/Preview/src/index.less b/src/components/Preview/src/index.less new file mode 100644 index 00000000000..0732a24dea9 --- /dev/null +++ b/src/components/Preview/src/index.less @@ -0,0 +1,118 @@ +.img-preview { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: @preview-comp-z-index; + background: rgba(0, 0, 0, 0.5); + user-select: none; + + &-content { + display: flex; + width: 100%; + height: 100%; + color: @white; + justify-content: center; + align-items: center; + } + + &-image { + cursor: pointer; + transition: transform 0.3s; + } + + &__close { + position: absolute; + top: -40px; + right: -40px; + width: 80px; + height: 80px; + overflow: hidden; + color: @white; + cursor: pointer; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + transition: all 0.2s; + + &-icon { + position: absolute; + top: 46px; + left: 16px; + font-size: 16px; + } + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + } + } + + &__index { + position: absolute; + bottom: 5%; + left: 50%; + padding: 0 22px; + font-size: 16px; + background: rgba(109, 109, 109, 0.6); + border-radius: 15px; + transform: translateX(-50%); + } + + &__controller { + position: absolute; + bottom: 10%; + left: 50%; + display: flex; + width: 260px; + height: 44px; + padding: 0 22px; + margin-left: -139px; + background: rgba(109, 109, 109, 0.6); + border-radius: 22px; + justify-content: center; + + &-item { + display: flex; + height: 100%; + padding: 0 9px; + font-size: 24px; + cursor: pointer; + transition: all 0.2s; + + &:hover { + transform: scale(1.2); + } + + img { + width: 1em; + } + } + } + + &__arrow { + position: absolute; + top: 50%; + display: flex; + align-items: center; + justify-content: center; + width: 50px; + height: 50px; + font-size: 28px; + cursor: pointer; + background-color: rgba(0, 0, 0, 0.5); + border-radius: 50%; + transition: all 0.2s; + + &:hover { + background-color: rgba(0, 0, 0, 0.8); + } + + &.left { + left: 50px; + } + + &.right { + right: 50px; + } + } +} diff --git a/src/components/Preview/src/index.tsx b/src/components/Preview/src/index.tsx new file mode 100644 index 00000000000..c674aa5c0e0 --- /dev/null +++ b/src/components/Preview/src/index.tsx @@ -0,0 +1,305 @@ +import './index.less'; + +import { defineComponent, ref, unref, computed, reactive, watchEffect } from 'vue'; + +// @ts-ignore +import { basicProps } from './props'; +// @ts-ignore +import { Props } from './types'; + +import { CloseOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons-vue'; +// import { Spin } from 'ant-design-vue'; + +import resumeSvg from '/@/assets/svg/preview/resume.svg'; +import rotateSvg from '/@/assets/svg/preview/p-rotate.svg'; +import scaleSvg from '/@/assets/svg/preview/scale.svg'; +import unScaleSvg from '/@/assets/svg/preview/unscale.svg'; +import unRotateSvg from '/@/assets/svg/preview/unrotate.svg'; +enum StatueEnum { + LOADING, + DONE, + FAIL, +} +interface ImgState { + currentUrl: string; + imgScale: number; + imgRotate: number; + imgTop: number; + imgLeft: number; + currentIndex: number; + status: StatueEnum; + moveX: number; + moveY: number; + show: boolean; +} + +const prefixCls = 'img-preview'; +export default defineComponent({ + name: 'ImagePreview', + props: basicProps, + setup(props: Props) { + const imgState = reactive({ + currentUrl: '', + imgScale: 1, + imgRotate: 0, + imgTop: 0, + imgLeft: 0, + status: StatueEnum.LOADING, + currentIndex: 0, + moveX: 0, + moveY: 0, + show: props.show, + }); + + const wrapElRef = ref(null); + const imgElRef = ref(null); + + // 初始化 + function init() { + initMouseWheel(); + const { index, imageList } = props; + + if (!imageList || !imageList.length) { + throw new Error('imageList is undefined'); + } + imgState.currentIndex = index; + handleIChangeImage(imageList[index]); + } + + // 重置 + function initState() { + imgState.imgScale = 1; + imgState.imgRotate = 0; + imgState.imgTop = 0; + imgState.imgLeft = 0; + } + + // 初始化鼠标滚轮事件 + function initMouseWheel() { + const wrapEl = unref(wrapElRef); + if (!wrapEl) { + return; + } + (wrapEl as any).onmousewheel = scrollFunc; + // 火狐浏览器没有onmousewheel事件,用DOMMouseScroll代替 + document.body.addEventListener('DOMMouseScroll', scrollFunc); + // 禁止火狐浏览器下拖拽图片的默认事件 + document.ondragstart = function () { + return false; + }; + } + + // 监听鼠标滚轮 + function scrollFunc(e: any) { + e = e || window.event; + e.delta = e.wheelDelta || -e.detail; + + e.preventDefault(); + if (e.delta > 0) { + // 滑轮向上滚动 + scaleFunc(0.015); + } + if (e.delta < 0) { + // 滑轮向下滚动 + scaleFunc(-0.015); + } + } + // 缩放函数 + function scaleFunc(num: number) { + if (imgState.imgScale <= 0.2 && num < 0) return; + imgState.imgScale += num; + } + + // 旋转图片 + function rotateFunc(deg: number) { + imgState.imgRotate += deg; + } + + // 鼠标事件 + function handleMouseUp() { + const imgEl = unref(imgElRef); + if (!imgEl) return; + imgEl.onmousemove = null; + } + + // 更换图片 + function handleIChangeImage(url: string) { + imgState.status = StatueEnum.LOADING; + const img = new Image(); + img.src = url; + img.onload = () => { + imgState.currentUrl = url; + imgState.status = StatueEnum.DONE; + }; + img.onerror = () => { + imgState.status = StatueEnum.FAIL; + }; + } + + // 关闭 + function handleClose(e: MouseEvent) { + e && e.stopPropagation(); + imgState.show = false; + // 移除火狐浏览器下的鼠标滚动事件 + document.body.removeEventListener('DOMMouseScroll', scrollFunc); + // 恢复火狐及Safari浏览器下的图片拖拽 + document.ondragstart = null; + } + + // 图片复原 + function resume() { + initState(); + } + + // 上一页下一页 + function handleChange(direction: 'left' | 'right') { + const { currentIndex } = imgState; + const { imageList } = props; + if (direction === 'left') { + imgState.currentIndex--; + if (currentIndex <= 0) { + imgState.currentIndex = imageList.length - 1; + } + } + if (direction === 'right') { + imgState.currentIndex++; + if (currentIndex >= imageList.length - 1) { + imgState.currentIndex = 0; + } + } + handleIChangeImage(imageList[imgState.currentIndex]); + } + + function handleAddMoveListener(e: MouseEvent) { + e = e || window.event; + imgState.moveX = e.clientX; + imgState.moveY = e.clientY; + const imgEl = unref(imgElRef); + if (imgEl) { + imgEl.onmousemove = moveFunc; + } + } + + function moveFunc(e: MouseEvent) { + e = e || window.event; + e.preventDefault(); + const movementX = e.clientX - imgState.moveX; + const movementY = e.clientY - imgState.moveY; + imgState.imgLeft += movementX; + imgState.imgTop += movementY; + imgState.moveX = e.clientX; + imgState.moveY = e.clientY; + } + + // 获取图片样式 + const getImageStyle = computed(() => { + const { imgScale, imgRotate, imgTop, imgLeft } = imgState; + return { + transform: `scale(${imgScale}) rotate(${imgRotate}deg)`, + marginTop: `${imgTop}px`, + marginLeft: `${imgLeft}px`, + }; + }); + + const getIsMultipleImage = computed(() => { + const { imageList } = props; + return imageList.length > 1; + }); + + watchEffect(() => { + if (props.show) { + init(); + } + if (props.imageList) { + initState(); + } + }); + + const renderClose = () => { + return ( +
+ +
+ ); + }; + + const renderIndex = () => { + if (!unref(getIsMultipleImage)) { + return null; + } + const { currentIndex } = imgState; + const { imageList } = props; + return ( +
+ {currentIndex + 1} / {imageList.length} +
+ ); + }; + + const renderController = () => { + return ( +
+
scaleFunc(-0.15)}> + +
+
scaleFunc(0.15)}> + +
+
+ +
+
rotateFunc(-90)}> + +
+
rotateFunc(90)}> + +
+
+ ); + }; + + const renderArrow = (direction: 'left' | 'right') => { + if (!unref(getIsMultipleImage)) { + return null; + } + return ( +
handleChange(direction)}> + {direction === 'left' ? : } +
+ ); + }; + + return () => { + return ( + imgState.show && ( +
+
+ {/*}*/} + {/* spinning={true}*/} + {/* class={[*/} + {/* `${prefixCls}-image`,*/} + {/* {*/} + {/* hidden: imgState.status !== StatueEnum.LOADING,*/} + {/* },*/} + {/* ]}*/} + {/*/>*/} + + {renderClose()} + {renderIndex()} + {renderController()} + {renderArrow('left')} + {renderArrow('right')} +
+
+ ) + ); + }; + }, +}); diff --git a/src/components/Preview/src/props.ts b/src/components/Preview/src/props.ts new file mode 100644 index 00000000000..c6d7c8af316 --- /dev/null +++ b/src/components/Preview/src/props.ts @@ -0,0 +1,15 @@ +import { PropType } from 'vue'; +export const basicProps = { + show: { + type: Boolean as PropType, + default: false, + }, + imageList: { + type: [Array] as PropType, + default: null, + }, + index: { + type: Number as PropType, + default: 0, + }, +}; diff --git a/src/components/Preview/src/types.ts b/src/components/Preview/src/types.ts new file mode 100644 index 00000000000..844b2541a8d --- /dev/null +++ b/src/components/Preview/src/types.ts @@ -0,0 +1,30 @@ +export interface Options { + show?: boolean; + imageList: string[]; + index?: number; +} + +export interface Props { + show: boolean; + instance: Props; + imageList: string[]; + index: number; +} + +export interface ImageProps { + alt?: string; + fallback?: string; + src: string; + width: string | number; + height?: string | number; + placeholder?: string | boolean; + preview?: + | boolean + | { + visible?: boolean; + onVisibleChange?: (visible: boolean, prevVisible: boolean) => void; + getContainer: string | HTMLElement | (() => HTMLElement); + }; +} + +export type ImageItem = string | ImageProps; diff --git a/src/views/demo/feat/img-preview/index.vue b/src/views/demo/feat/img-preview/index.vue index e5b3f00c9a6..cadae76a1b3 100644 --- a/src/views/demo/feat/img-preview/index.vue +++ b/src/views/demo/feat/img-preview/index.vue @@ -1,11 +1,12 @@