diff --git a/devui/image-preview/__tests__/image-preview.spec.ts b/devui/image-preview/__tests__/image-preview.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..519e4e62b15176d7b55b1b19f9ddc51a3443d28c --- /dev/null +++ b/devui/image-preview/__tests__/image-preview.spec.ts @@ -0,0 +1,87 @@ +import { mount } from '@vue/test-utils' +import { ImagePreviewDirective } from '../index' +import { ref } from 'vue' + +// 指令图片模板 +const imageTemplate = ` + + +` +// 全局属性 +const global = { + directives: { + dImagePreview: ImagePreviewDirective + } +} + +describe('image-preview', () => { + it('image-preview click', async () => { + const wrapper = mount( + { + template: ` +
+ ${imageTemplate} +
+ ` + }, + { + global + } + ) + const img = wrapper.find('#testImg') + await img.trigger('click') + const ele = document.querySelector('.devui-image-preview-main-image') + expect(ele).toBeTruthy() + const closeBtn = document.querySelector('.devui-image-preview-close-btn') as any + closeBtn.click() + }) + + it('image-preview disableDefault', async () => { + const wrapper = mount( + { + template: ` +
+ ${imageTemplate} +
+ ` + }, + { + global + } + ) + const img = wrapper.find('#testImg') + await img.trigger('click') + const ele = document.querySelector('.devui-image-preview-main-image') + expect(ele).toBeFalsy() + }) + + it('image-preview custom', async () => { + const custom: any = ref({}) + const open = () => custom.value.open() + const wrapper = mount( + { + template: ` +
+ ${imageTemplate} +
+ + `, + setup() { + return { + custom, + open + } + } + }, + { + global + } + ) + const customBtn = wrapper.find('#open') + await customBtn.trigger('click') + const ele = document.querySelector('.devui-image-preview-main-image') + expect(ele).toBeTruthy() + const closeBtn = document.querySelector('.devui-image-preview-close-btn') as any + closeBtn.click() + }) +}) diff --git a/devui/image-preview/index.ts b/devui/image-preview/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8125149636faf834987011197648ba34771f38e0 --- /dev/null +++ b/devui/image-preview/index.ts @@ -0,0 +1,14 @@ +import type { App } from 'vue' +import ImagePreviewDirective from './src/image-preview-directive' +import ImagePreviewService from './src/image-preview-service' + +export { ImagePreviewDirective, ImagePreviewService } + +export default { + title: 'ImagePreview 图片预览', + category: '数据展示', + install(app: App): void { + app.directive('d-image-preview', ImagePreviewDirective) + app.config.globalProperties.$imagePreviewService = ImagePreviewService + } +} diff --git a/devui/image-preview/src/image-preview-directive.ts b/devui/image-preview/src/image-preview-directive.ts new file mode 100644 index 0000000000000000000000000000000000000000..5317dff6d37f419071351c395870ed25f4420ade --- /dev/null +++ b/devui/image-preview/src/image-preview-directive.ts @@ -0,0 +1,52 @@ +import { BindingTypes } from './image-preview-types' +import ImagePreviewService from './image-preview-service' + +function mountedPreviewImages(url: string, urlList: Array): void { + ImagePreviewService.open({ url, previewUrlList: urlList }) +} +function unmountedPreviewImages() { + ImagePreviewService.close() +} + +function getImgByEl(el: HTMLElement): Array { + const urlList = [...el.querySelectorAll('img')].map((item: HTMLImageElement) => + item.getAttribute('src') + ) + return urlList +} + +function handleImgByEl(el: HTMLElement) { + el.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation() + const target = e.target as HTMLElement + if (target?.nodeName?.toLowerCase() === 'img') { + const urlList = getImgByEl(el) + const url = target.getAttribute('src') + mountedPreviewImages(url, urlList) + } + }) +} + +export default { + mounted(el: HTMLElement, binding: BindingTypes | undefined) { + if (!binding.value) { + return handleImgByEl(el) + } + const { custom, disableDefault } = binding.value + // console.log('指令参数:', custom, disableDefault, zIndex, backDropZIndex) + if (custom instanceof Object) { + custom.open = () => { + const urlList = getImgByEl(el) + mountedPreviewImages(urlList?.[0], urlList) + } + custom.close = () => unmountedPreviewImages() + } + if (disableDefault) { + return + } + handleImgByEl(el) + }, + unmounted() { + unmountedPreviewImages() + } +} diff --git a/devui/image-preview/src/image-preview-service.ts b/devui/image-preview/src/image-preview-service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd26b5c9e471140e3151f2f5b6941f4f44a6a0b3 --- /dev/null +++ b/devui/image-preview/src/image-preview-service.ts @@ -0,0 +1,34 @@ +import { createApp } from 'vue' +import { ImagePreviewProps } from './image-preview-types' +import imagePreview from './image-preview' + +function createComponent(props: ImagePreviewProps) { + return createApp(imagePreview, props) +} + +class ImagePreviewService { + static $body: HTMLElement | null = null + static $div: HTMLDivElement | null = null + // 暂时的禁止滚动穿透,后续应该考虑用modal组件来渲染预览组件 + static $overflow = '' + + static open(props: ImagePreviewProps) { + this.$body = document.body + this.$div = document.createElement('div') + this.$overflow = this.$body.style.overflow + this.$body.appendChild(this.$div) + createComponent(props).mount(this.$div) + + this.$body.style.setProperty('overflow', 'hidden', 'important') + } + static close() { + this.$body?.style.setProperty('overflow', this.$overflow) + this.$overflow = null + + this.$div && this.$body.removeChild(this.$div) + this.$body = null + this.$div = null + } +} + +export default ImagePreviewService diff --git a/devui/image-preview/src/image-preview-types.ts b/devui/image-preview/src/image-preview-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f4053cb665ef1edabc0a59b2d25bb748cc7c945 --- /dev/null +++ b/devui/image-preview/src/image-preview-types.ts @@ -0,0 +1,24 @@ +import type { PropType, ExtractPropTypes } from 'vue' + +export const imagePreviewProps = { + url: { + type: String, + default: '' + }, + previewUrlList: { + type: Array as PropType, + default: () => [] + } +} as const + +export interface BindingTypes { + value: { + custom?: any + disableDefault?: boolean + zIndex?: number + backDropZIndex?: number + } + [key: string]: any +} + +export type ImagePreviewProps = ExtractPropTypes diff --git a/devui/image-preview/src/image-preview.scss b/devui/image-preview/src/image-preview.scss new file mode 100644 index 0000000000000000000000000000000000000000..28a4f786dd980e419912750b48f772bc6009ef34 --- /dev/null +++ b/devui/image-preview/src/image-preview.scss @@ -0,0 +1,115 @@ +@import '../../style/theme/_z-index'; +@import '../../style/theme/_shadow'; +@import '../../style/theme/_color'; +@import '../../style/theme/_corner'; + +.devui-image-preview { + position: fixed; + left: 0; + top: 0; + right: 0; + bottom: 0; + z-index: calc(#{$devui-z-index-modal} - 1); + background: $devui-shadow; + border-radius: $devui-border-radius; + box-shadow: $devui-shadow-length-fullscreen-overlay $devui-shadow; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + + svg, + polygon, + g, + path { + fill: $devui-icon-text; + } + @mixin fixed-button() { + position: fixed; + z-index: $devui-z-index-modal; + cursor: pointer; + width: 36px; + height: 36px; + border-radius: 50%; + background: $devui-highlight-overlay; + box-shadow: $devui-shadow-length-base $devui-light-shadow; + display: inline-flex; + align-items: center; + justify-content: center; + + &:hover { + background: $devui-area; + } + + svg { + width: 38px; + height: 18px; + } + } + + &-main-image { + width: auto; + height: auto; + max-width: 90%; + max-height: 90%; + margin-top: -20px; + cursor: grab; + } + + &-close-btn { + @include fixed-button(); + + top: 15px; + right: 20px; + } + + &-arrow-left { + @include fixed-button(); + + top: 50%; + left: 20px; + transform: translateY(-50%); + } + + &-arrow-right { + @include fixed-button(); + + top: 50%; + right: 20px; + transform: translateY(-50%); + } + + &-toolbar { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: $devui-highlight-overlay; + box-shadow: $devui-shadow-length-fullscreen-overlay $devui-light-shadow; + + button { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + color: $devui-text; + } + + .devui-image-preview-index { + display: inline-flex; + width: 100px; + justify-content: center; + align-items: center; + cursor: pointer; + } + + & > :not(:first-child) { + margin-left: 20px; + } + } +} diff --git a/devui/image-preview/src/image-preview.tsx b/devui/image-preview/src/image-preview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e389d3391f81bdf153b3e0d07e91e8302d970f1f --- /dev/null +++ b/devui/image-preview/src/image-preview.tsx @@ -0,0 +1,170 @@ +import './image-preview.scss' +import { defineComponent, ref, computed, onMounted } from 'vue' +import { imagePreviewProps, ImagePreviewProps } from './image-preview-types' +import ImagePreviewService from './image-preview-service' +import Transform from './transform' + +export default defineComponent({ + name: 'DImagePreview', + props: imagePreviewProps, + emits: [], + setup(props: ImagePreviewProps) { + let transform: Transform = null + const index = ref(0) + const url = computed(() => props.previewUrlList[index.value]) + + function initTransform() { + const imageElement: HTMLImageElement = document.querySelector( + '.devui-image-preview-main-image' + ) + transform = new Transform(imageElement) + } + function initIndex() { + index.value = props.previewUrlList.findIndex(curUrl => curUrl === props.url) + } + function onPrev() { + index.value = index.value <= 0 ? props.previewUrlList.length - 1 : index.value - 1 + } + function onNext() { + index.value = index.value >= props.previewUrlList.length - 1 ? 0 : index.value + 1 + } + function onClose() { + ImagePreviewService.close() + } + function onZoomIn() { + transform.setZoomIn() + } + function onZoomOut() { + transform.setZoomOut() + } + function onRotate() { + transform.setRotate() + } + function onZoomBest() { + transform.setZoomBest() + } + function onZoomOriginal() { + transform.setZoomOriginal() + } + + onMounted(() => { + initIndex() + initTransform() + }) + + return () => { + return ( +
+ {/* 预览图 */} + + {/* 按钮区 */} + + + + {/* 底部固定区 */} +
+ + + + + + {index.value + 1}:{props.previewUrlList.length} + + + + +
+
+ ) + } + } +}) diff --git a/devui/image-preview/src/transform.ts b/devui/image-preview/src/transform.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff35f90fab0f2b9b8a46424967c5509bf49e3ee5 --- /dev/null +++ b/devui/image-preview/src/transform.ts @@ -0,0 +1,130 @@ +interface Options { + transformX?: number + transformY?: number + zoom?: number + rotate?: number +} +interface HTMLElementPlus extends HTMLElement { + onmousewheel?: (...args: any[]) => void +} + +export default class Transform { + private el: HTMLElementPlus + + private oTransformX = 0 + private oTransformY = 0 + private transformX: number + private transformY: number + private zoom: number + private rotate: number + + private STEP = 0.25 + private MIN_SCALE = 0.2 + private MAX_SCALE = 2.5 + private TRANSFORMX = 0 + private TRANSFORMY = 0 + private ZOOM = 1 + private ROTATE = 0 + + constructor(el: HTMLElementPlus, options: Options = {}) { + this.el = el + this.transformX = options.transformX || this.TRANSFORMX + this.transformY = options.transformY || this.TRANSFORMY + this.zoom = options.zoom || this.ZOOM + this.rotate = options.rotate || this.ROTATE + + this.handleDefaultDraggable() + this.onDraggable() + this.onMouseWheel() + } + handleDefaultDraggable() { + document.body.ondragstart = () => { + window.event.returnValue = false + return false + } + } + onDraggable() { + this.el.onmousedown = (e: MouseEvent) => { + const ox = e.clientX + const oy = e.clientY + document.onmousemove = (e1: MouseEvent) => { + const disX = e1.clientX - ox + const disY = e1.clientY - oy + this.transformX = this.oTransformX + disX + this.transformY = this.oTransformY + disY + this.el.style.cursor = 'grabbing' + this.setPosition() + } + } + document.onmouseup = () => { + document.onmousemove = null + this.oTransformX = this.transformX + this.oTransformY = this.transformY + this.el.style.cursor = 'grab' + } + } + onMouseWheel() { + const handleWheel = this.throttle(this.setMouseWheel, 100) + this.el.onmousewheel = e => { + const value: number = -e.wheelDelta || e.deltaY || e.detail + handleWheel(value) + } + } + throttle(fn: (...args: any[]) => void, t: number) { + let timer = null + return (...args) => { + if (!timer) { + setTimeout(() => { + timer = null + fn.apply(this, args) + }, t) + } + } + } + setMouseWheel(value: number) { + if (value < 0) { + if (this.zoom >= this.MAX_SCALE) { + this.el.style.cursor = 'not-allowed' + return + } + this.el.style.cursor = 'zoom-in' + this.setZoomIn(this.STEP) + } else { + if (this.zoom <= this.MIN_SCALE) { + this.el.style.cursor = 'not-allowed' + return + } + this.el.style.cursor = 'zoom-out' + this.setZoomOut(this.STEP) + } + this.setPosition() + } + setZoomIn(step = this.STEP) { + this.zoom = Math.min(this.MAX_SCALE, this.zoom + step) + this.setPosition() + } + setZoomOut(step = this.STEP) { + this.zoom = Math.max(this.MIN_SCALE, this.zoom - step) + this.setPosition() + } + setZoomBest() { + this.reset() + this.setPosition() + } + setZoomOriginal() { + this.reset() + this.setPosition() + } + setRotate() { + this.rotate += 0.25 + this.setPosition() + } + reset() { + this.transformX = this.TRANSFORMX + this.transformY = this.TRANSFORMY + this.zoom = this.ZOOM + } + setPosition() { + this.el.style.transform = `translate(${this.transformX}px, ${this.transformY}px) scale(${this.zoom}) rotate(${this.rotate}turn)` + } +} diff --git a/sites/components/image-preview/index.md b/sites/components/image-preview/index.md new file mode 100644 index 0000000000000000000000000000000000000000..20867c52e01ec4dcb4da29770b171c862ad99727 --- /dev/null +++ b/sites/components/image-preview/index.md @@ -0,0 +1,137 @@ +# ImagePreview 图片预览 + +预览一张或多张图片的组件。 + +### 何时使用 + +需要根据用户传入进行图片预览展示或对容器内图片进行预览时。 + +### 基本用法 + +使用 v-d-image-preview 指令,对容器内图片进行预览。 + +
+ +
+ +```html +
+ +
+ + +``` + +### 自定义开启预览窗口 + +传入 custom 参数,指令会自动注入 open 方法,通过 custom.open 开启预览窗口 + +
+ +
+自定义 + +```html +
+ +
+自定义 + + +``` + +### 设置 zIndex + +通过设置 zIndex 控制弹出效果的层级,设置 backDropZIndex 控制弹出层背景的层级。 +可以看到当设置 zIndex 小于 backDropZIndex 时,imagePreview 会显示在背景下方。 +可以通过 Esc 关闭 imagePreview。 + +``` +// 待嵌入 modal 组件即可 +``` + +### API + +| 参数 | 类型 | 默认 | 说明 | +| :------------: | :-------: | :---: | :------------------------------------------------------------ | +| custom | `Object` | -- | 可选,指令会自动注入 open 方法,通过 custom.open 开启预览窗口 | +| disableDefault | `Boolean` | false | 可选,关闭默认点击触发图片预览方式 | +| zIndex | `Number` | 1050 | 可选,可选,设置预览时图片的 z-index 值 | +| backDropZIndex | `Number` | 1040 | 可选,设置预览时图片背景的 z-index 值 | + + + +