mirror of
https://gitee.com/element-plus/element-plus.git
synced 2024-12-03 03:38:41 +08:00
feat: add image component (#124)
This commit is contained in:
parent
2b2d085992
commit
9ca93eaa40
@ -78,3 +78,4 @@ describe('Avatar.vue', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -13,6 +13,7 @@ import ElTimeline from '@element-plus/timeline'
|
||||
import ElProgress from '@element-plus/progress'
|
||||
import ElBreadcrumb from '@element-plus/breadcrumb'
|
||||
import ElIcon from '@element-plus/icon'
|
||||
import ElImage from '@element-plus/image'
|
||||
import ElLink from '@element-plus/link'
|
||||
import ElRate from '@element-plus/rate'
|
||||
import ElSwitch from '@element-plus/switch'
|
||||
@ -42,6 +43,7 @@ export {
|
||||
ElProgress,
|
||||
ElBreadcrumb,
|
||||
ElIcon,
|
||||
ElImage,
|
||||
ElLink,
|
||||
ElRate,
|
||||
ElSwitch,
|
||||
@ -71,6 +73,7 @@ export default function install(app: App): void {
|
||||
ElProgress(app)
|
||||
ElBreadcrumb(app)
|
||||
ElIcon(app)
|
||||
ElImage(app)
|
||||
ElLink(app)
|
||||
ElRate(app)
|
||||
ElSwitch(app)
|
||||
|
@ -28,6 +28,7 @@
|
||||
"@element-plus/link": "^0.0.0",
|
||||
"@element-plus/progress": "^0.0.0",
|
||||
"@element-plus/rate": "^0.0.0",
|
||||
"@element-plus/image": "^0.0.0",
|
||||
"@element-plus/switch": "^0.0.0",
|
||||
"@element-plus/radio": "^0.0.0",
|
||||
"@element-plus/page-header": "^0.0.0",
|
||||
|
123
packages/image/__tests__/image.spec.ts
Normal file
123
packages/image/__tests__/image.spec.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Image from '../src/index.vue'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { IMAGE_SUCCESS, IMAGE_FAIL } from '../../test-utils/mock'
|
||||
|
||||
|
||||
describe('Image.vue', () => {
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(global.Image.prototype, 'src', {
|
||||
set(src) {
|
||||
const type = !src || src === IMAGE_FAIL ? 'error' : 'load'
|
||||
const event = new Event(type)
|
||||
this.dispatchEvent(event)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test('render test', () => {
|
||||
const wrapper = mount(Image)
|
||||
expect(wrapper.find('.el-image').exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('image load success test', async () => {
|
||||
const alt = 'this ia alt'
|
||||
const wrapper = mount(Image, {
|
||||
props: {
|
||||
src: IMAGE_SUCCESS,
|
||||
alt,
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('.el-image__placeholder').exists()).toBe(true)
|
||||
await nextTick()
|
||||
expect(wrapper.find('.el-image__inner').exists()).toBe(true)
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
expect(wrapper.find('.el-image__placeholder').exists()).toBe(false)
|
||||
expect(wrapper.find('.el-image__error').exists()).toBe(false)
|
||||
})
|
||||
|
||||
test('image load error test', async () => {
|
||||
const wrapper = mount(Image, {
|
||||
props: {
|
||||
src: IMAGE_FAIL,
|
||||
},
|
||||
})
|
||||
expect(wrapper.emitted('error')).toBeDefined()
|
||||
await nextTick()
|
||||
expect(wrapper.find('.el-image__inner').exists()).toBe(false)
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.find('.el-image__error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('imageStyle fit test', async () => {
|
||||
const fits = ['fill', 'contain', 'cover', 'none', 'scale-down']
|
||||
for (const fit of fits) {
|
||||
const wrapper = mount(Image, {
|
||||
props: { fit, src: IMAGE_SUCCESS },
|
||||
})
|
||||
await nextTick()
|
||||
expect(wrapper.find('img').attributes('style')).toContain(`object-fit: ${fit};`)
|
||||
}
|
||||
})
|
||||
|
||||
test('preview classname test', async () => {
|
||||
const wrapper = mount(Image, {
|
||||
props: {
|
||||
fit: 'cover',
|
||||
src: IMAGE_SUCCESS,
|
||||
previewSrcList: new Array(3).fill(IMAGE_SUCCESS),
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
expect(wrapper.find('img').classes()).toContain('el-image__preview')
|
||||
})
|
||||
|
||||
|
||||
test('$attrs', async () => {
|
||||
const alt = 'this ia alt'
|
||||
const wrapper = mount(Image, {
|
||||
props: {
|
||||
src: IMAGE_SUCCESS,
|
||||
alt,
|
||||
referrerpolicy: 'origin',
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
expect(wrapper.find('img').attributes('alt')).toBe(alt)
|
||||
expect(wrapper.find('img').attributes('referrerpolicy')).toBe('origin')
|
||||
})
|
||||
|
||||
test('pass event listeners', async() => {
|
||||
let result = false
|
||||
const wrapper = mount(Image, {
|
||||
props: {
|
||||
src: IMAGE_SUCCESS,
|
||||
onClick: () => result = true,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
const inner = wrapper.find('.el-image__inner').element as HTMLElement
|
||||
inner.click()
|
||||
expect(result).toBeTruthy()
|
||||
})
|
||||
|
||||
//@todo lazy image test
|
||||
|
||||
test('big image preview', async() => {
|
||||
const wrapper = mount(Image, {
|
||||
props: {
|
||||
src: IMAGE_SUCCESS,
|
||||
previewSrcList: [IMAGE_SUCCESS],
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
expect(wrapper.find('.el-image__inner').exists()).toBe(true)
|
||||
await wrapper.find('.el-image__inner').trigger('click')
|
||||
const viewer = wrapper.find('.el-image-viewer__wrapper')
|
||||
expect(viewer.exists()).toBe(true)
|
||||
await wrapper.find('.el-image-viewer__close').trigger('click')
|
||||
expect(wrapper.find('.el-image-viewer__wrapper').exists()).toBe(false)
|
||||
})
|
||||
})
|
40
packages/image/doc/basic.vue
Normal file
40
packages/image/doc/basic.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="demo-image">
|
||||
<div v-for="fit in fits" :key="fit" class="block">
|
||||
<div class="demonstration">{{ fit }}</div>
|
||||
<br>
|
||||
<el-image
|
||||
style="width: 100px; height: 100px"
|
||||
:src="url"
|
||||
:fit="fit"
|
||||
alt="111"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
fits: ['fill', 'contain', 'cover', 'none', 'scale-down'],
|
||||
url: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-image {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.block {
|
||||
margin: 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.demo-image .demonstration {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
26
packages/image/doc/bigImage.vue
Normal file
26
packages/image/doc/bigImage.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div class="demo-image__preview">
|
||||
<el-image
|
||||
style="width: 100px; height: 100px"
|
||||
:src="url"
|
||||
:preview-src-list="srcList"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
url: 'https://fuss10.elemecdn.com/e/5d/4a731a90594a4af544c0c25941171jpeg.jpeg',
|
||||
srcList: [
|
||||
'https://fuss10.elemecdn.com/8/27/f01c15bb73e1ef3793e64e6b7bbccjpeg.jpeg',
|
||||
'https://fuss10.elemecdn.com/1/8e/aeffeb4de74e2fde4bd74fc7b4486jpeg.jpeg',
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
54
packages/image/doc/error.vue
Normal file
54
packages/image/doc/error.vue
Normal file
@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="demo-image__error">
|
||||
<div class="block">
|
||||
<span class="demonstration">默认</span>
|
||||
<el-image />
|
||||
</div>
|
||||
<div class="block">
|
||||
<span class="demonstration">自定义</span>
|
||||
<el-image>
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<i class="el-icon-picture-outline"></i>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.demo-image__error .block {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
border-right: 1px solid #eff2f6;
|
||||
display: inline-block;
|
||||
width: 49%;
|
||||
box-sizing: border-box;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
}
|
||||
.demo-image__error .image-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.demo-image__error .demonstration {
|
||||
display: block;
|
||||
color: #8492a6;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.demo-image__error .el-image {
|
||||
width: 300px;
|
||||
height: 200px;
|
||||
}
|
||||
</style>
|
10
packages/image/doc/index.stories.ts
Normal file
10
packages/image/doc/index.stories.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { default as BasicUsage } from './basic.vue'
|
||||
export { default as LoadError } from './error.vue'
|
||||
export { default as LoadPlaceholder } from './placeholder.vue'
|
||||
export { default as LazyLoad } from './lazyload.vue'
|
||||
export { default as BigImage } from './bigImage.vue'
|
||||
|
||||
export default {
|
||||
title: 'Image',
|
||||
}
|
||||
|
44
packages/image/doc/lazyload.vue
Normal file
44
packages/image/doc/lazyload.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<div class="demo-image__lazy" style="overflow: auto;height: 400px;">
|
||||
<el-image
|
||||
v-for="url in urls"
|
||||
:key="url"
|
||||
:src="url"
|
||||
lazy
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
urls: [
|
||||
'https://fuss10.elemecdn.com/a/3f/3302e58f9a181d2509f3dc0fa68b0jpeg.jpeg',
|
||||
'https://fuss10.elemecdn.com/1/34/19aa98b1fcb2781c4fba33d850549jpeg.jpeg',
|
||||
'https://fuss10.elemecdn.com/0/6f/e35ff375812e6b0020b6b4e8f9583jpeg.jpeg',
|
||||
'https://fuss10.elemecdn.com/9/bb/e27858e973f5d7d3904835f46abbdjpeg.jpeg',
|
||||
'https://fuss10.elemecdn.com/d/e6/c4d93a3805b3ce3f323f7974e6f78jpeg.jpeg',
|
||||
'https://fuss10.elemecdn.com/3/28/bbf893f792f03a54408b3b7a7ebf0jpeg.jpeg',
|
||||
'https://fuss10.elemecdn.com/2/11/6535bcfb26e4c79b48ddde44f4b6fjpeg.jpeg',
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-image__lazy {
|
||||
height: 400px;
|
||||
overflow: auto;
|
||||
}
|
||||
.demo-image__lazy .el-image {
|
||||
display: block;
|
||||
min-height: 250px;
|
||||
margin-bottom: 10px;
|
||||
width: 50%;
|
||||
}
|
||||
.demo-image__lazy .el-image:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
62
packages/image/doc/placeholder.vue
Normal file
62
packages/image/doc/placeholder.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="demo-image__placeholder">
|
||||
<div class="block">
|
||||
<span class="demonstration">默认</span>
|
||||
<el-image :src="src" />
|
||||
</div>
|
||||
<div class="block">
|
||||
<span class="demonstration">自定义</span>
|
||||
<el-image :src="src">
|
||||
<template #placeholder>
|
||||
<div class="image-slot">
|
||||
加载中<span class="dot">...</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
src: 'https://cube.elemecdn.com/6/94/4d3ea53c084bad6931a56d5158a48jpeg.jpeg',
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.demo-image__placeholder .block {
|
||||
padding: 30px 0;
|
||||
text-align: center;
|
||||
border-right: 1px solid #eff2f6;
|
||||
display: inline-block;
|
||||
width: 49%;
|
||||
box-sizing: border-box;
|
||||
vertical-align: top;
|
||||
margin: 0;
|
||||
}
|
||||
.demo-image__placeholder .image-slot {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f7fa;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.demo-image__placeholder .el-image {
|
||||
width: 300px;
|
||||
height: 200px;
|
||||
}
|
||||
.demo-image__placeholder .demonstration {
|
||||
display: block;
|
||||
color: #8492a6;
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
5
packages/image/index.ts
Normal file
5
packages/image/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { App } from 'vue'
|
||||
import Image from './src/index.vue'
|
||||
export default (app: App): void => {
|
||||
app.component(Image.name, Image)
|
||||
}
|
12
packages/image/package.json
Normal file
12
packages/image/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@element-plus/image",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.js",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0-rc.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/test-utils": "^2.0.0-beta.0"
|
||||
}
|
||||
}
|
357
packages/image/src/image-viewer.vue
Normal file
357
packages/image/src/image-viewer.vue
Normal file
@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<transition name="viewer-fade">
|
||||
<div
|
||||
ref="wrapper"
|
||||
tabindex="-1"
|
||||
class="el-image-viewer__wrapper"
|
||||
:style="{ 'z-index': zIndex }"
|
||||
>
|
||||
<div class="el-image-viewer__mask"></div>
|
||||
<!-- CLOSE -->
|
||||
<span class="el-image-viewer__btn el-image-viewer__close" @click="hide">
|
||||
<i class="el-icon-circle-close"></i>
|
||||
</span>
|
||||
<!-- ARROW -->
|
||||
<template v-if="!isSingle">
|
||||
<span
|
||||
class="el-image-viewer__btn el-image-viewer__prev"
|
||||
:class="{ 'is-disabled': !infinite && isFirst }"
|
||||
@click="prev"
|
||||
>
|
||||
<i class="el-icon-arrow-left"></i>
|
||||
</span>
|
||||
<span
|
||||
class="el-image-viewer__btn el-image-viewer__next"
|
||||
:class="{ 'is-disabled': !infinite && isLast }"
|
||||
@click="next"
|
||||
>
|
||||
<i class="el-icon-arrow-right"></i>
|
||||
</span>
|
||||
</template>
|
||||
<!-- ACTIONS -->
|
||||
<div class="el-image-viewer__btn el-image-viewer__actions">
|
||||
<div class="el-image-viewer__actions__inner">
|
||||
<i class="el-icon-zoom-out" @click="handleActions('zoomOut')"></i>
|
||||
<i class="el-icon-zoom-in" @click="handleActions('zoomIn')"></i>
|
||||
<i class="el-image-viewer__actions__divider"></i>
|
||||
<i :class="mode.icon" @click="toggleMode"></i>
|
||||
<i class="el-image-viewer__actions__divider"></i>
|
||||
<i class="el-icon-refresh-left" @click="handleActions('anticlocelise')"></i>
|
||||
<i class="el-icon-refresh-right" @click="handleActions('clocelise')"></i>
|
||||
</div>
|
||||
</div>
|
||||
<!-- CANVAS -->
|
||||
<div class="el-image-viewer__canvas">
|
||||
<img
|
||||
v-for="(url, i) in urlList"
|
||||
v-show="i === index"
|
||||
ref="img"
|
||||
:key="url"
|
||||
:src="currentImg"
|
||||
:style="imgStyle"
|
||||
class="el-image-viewer__img"
|
||||
@load="handleImgLoad"
|
||||
@error="handleImgError"
|
||||
@mousedown="handleMouseDown"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script lang='ts'>
|
||||
|
||||
import { defineComponent, computed, ref, onMounted, watch, nextTick, PropType } from 'vue'
|
||||
import { rafThrottle, isFirefox } from '@element-plus/utils/util'
|
||||
import { on, off } from '@element-plus/utils/dom'
|
||||
import { eventKeys } from '@element-plus/utils/aria'
|
||||
import { t } from '@element-plus/locale'
|
||||
|
||||
const Mode = {
|
||||
CONTAIN: {
|
||||
name: 'contain',
|
||||
icon: 'el-icon-full-screen',
|
||||
},
|
||||
ORIGINAL: {
|
||||
name: 'original',
|
||||
icon: 'el-icon-c-scale-to-original',
|
||||
},
|
||||
}
|
||||
|
||||
const mousewheelEventName = isFirefox() ? 'DOMMouseScroll' : 'mousewheel'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ElImageViewer',
|
||||
props: {
|
||||
urlList: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 2000,
|
||||
},
|
||||
onSwitch: {
|
||||
type: Function,
|
||||
default: () => ({}),
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => ({}),
|
||||
},
|
||||
initialIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
// init here
|
||||
|
||||
let _keyDownHandler = null
|
||||
let _mouseWheelHandler = null
|
||||
let _dragHandler = null
|
||||
|
||||
const loading = ref(true)
|
||||
const index = ref(props.initialIndex)
|
||||
const infinite = ref(true)
|
||||
const wrapper = ref(null)
|
||||
const img = ref(null)
|
||||
const mode = ref(Mode.CONTAIN)
|
||||
let transform = ref({
|
||||
scale: 1,
|
||||
deg: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
enableTransition: false,
|
||||
})
|
||||
|
||||
const isSingle = computed(() => {
|
||||
const { urlList } = props
|
||||
return urlList.length <= 1
|
||||
})
|
||||
|
||||
const isFirst = computed(() => {
|
||||
return index.value === 0
|
||||
})
|
||||
|
||||
const isLast = computed(() => {
|
||||
return index.value === 0
|
||||
})
|
||||
|
||||
const currentImg = computed(() => {
|
||||
return props.urlList[index.value]
|
||||
})
|
||||
|
||||
const imgStyle = computed(() => {
|
||||
const { scale, deg, offsetX, offsetY, enableTransition } = transform.value
|
||||
const style = {
|
||||
transform: `scale(${scale}) rotate(${deg}deg)`,
|
||||
transition: enableTransition ? 'transform .3s' : '',
|
||||
'margin-left': `${offsetX}px`,
|
||||
'margin-top': `${offsetY}px`,
|
||||
}
|
||||
if (mode.value.name === Mode.CONTAIN.name) {
|
||||
style.maxWidth = style.maxHeight = '100%'
|
||||
}
|
||||
return style
|
||||
})
|
||||
|
||||
function hide() {
|
||||
deviceSupportUninstall()
|
||||
props.onClose()
|
||||
}
|
||||
|
||||
function deviceSupportInstall() {
|
||||
_keyDownHandler = rafThrottle(e => {
|
||||
const keyCode = e.keyCode
|
||||
switch (keyCode) {
|
||||
// ESC
|
||||
case eventKeys.esc:
|
||||
hide()
|
||||
break
|
||||
// SPACE
|
||||
case eventKeys.space:
|
||||
toggleMode()
|
||||
break
|
||||
// LEFT_ARROW
|
||||
case eventKeys.left:
|
||||
prev()
|
||||
break
|
||||
// UP_ARROW
|
||||
case eventKeys.up:
|
||||
handleActions('zoomIn')
|
||||
break
|
||||
// RIGHT_ARROW
|
||||
case eventKeys.right:
|
||||
next()
|
||||
break
|
||||
// DOWN_ARROW
|
||||
case eventKeys.down:
|
||||
handleActions('zoomOut')
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
_mouseWheelHandler = rafThrottle(e => {
|
||||
const delta = e.wheelDelta ? e.wheelDelta : -e.detail
|
||||
if (delta > 0) {
|
||||
handleActions('zoomIn', {
|
||||
zoomRate: 0.015,
|
||||
enableTransition: false,
|
||||
})
|
||||
} else {
|
||||
handleActions('zoomOut', {
|
||||
zoomRate: 0.015,
|
||||
enableTransition: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
on(document, 'keydown', _keyDownHandler)
|
||||
on(document, mousewheelEventName, _mouseWheelHandler)
|
||||
}
|
||||
|
||||
function deviceSupportUninstall() {
|
||||
off(document, 'keydown', _keyDownHandler)
|
||||
off(document, mousewheelEventName, _mouseWheelHandler)
|
||||
_keyDownHandler = null
|
||||
_mouseWheelHandler = null
|
||||
}
|
||||
|
||||
function handleImgLoad() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function handleImgError(e) {
|
||||
loading.value = false
|
||||
e.target.alt = t('el.image.error')
|
||||
}
|
||||
|
||||
function handleMouseDown(e) {
|
||||
if (loading.value || e.button !== 0) return
|
||||
|
||||
const { offsetX, offsetY } = transform.value
|
||||
const startX = e.pageX
|
||||
const startY = e.pageY
|
||||
_dragHandler = rafThrottle(ev => {
|
||||
transform.value = {
|
||||
...transform.value,
|
||||
offsetX: offsetX + ev.pageX - startX,
|
||||
offsetY: offsetY + ev.pageY - startY,
|
||||
}
|
||||
})
|
||||
on(document, 'mousemove', _dragHandler)
|
||||
on(document, 'mouseup', () => {
|
||||
off(document, 'mousemove', _dragHandler)
|
||||
})
|
||||
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
transform.value = {
|
||||
scale: 1,
|
||||
deg: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0,
|
||||
enableTransition: false,
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMode() {
|
||||
if (loading.value) return
|
||||
|
||||
const modeNames = Object.keys(Mode)
|
||||
const modeValues = Object.values(Mode)
|
||||
const currentMode = mode.value.name
|
||||
const index = modeValues.findIndex(i => i.name === currentMode)
|
||||
const nextIndex = (index + 1) % modeNames.length
|
||||
mode.value = Mode[modeNames[nextIndex]]
|
||||
reset()
|
||||
}
|
||||
|
||||
function prev() {
|
||||
if (isFirst.value && !infinite.value) return
|
||||
const len = props.urlList.length
|
||||
index.value = (index.value - 1 + len) % len
|
||||
}
|
||||
|
||||
function next() {
|
||||
if (isLast.value && !infinite.value) return
|
||||
const len = props.urlList.length
|
||||
index.value = (index.value + 1) % len
|
||||
}
|
||||
|
||||
function handleActions(action, options = {}) {
|
||||
if (loading.value) return
|
||||
const { zoomRate, rotateDeg, enableTransition } = {
|
||||
zoomRate: 0.2,
|
||||
rotateDeg: 90,
|
||||
enableTransition: true,
|
||||
...options,
|
||||
}
|
||||
switch (action) {
|
||||
case 'zoomOut':
|
||||
if (transform.value.scale > 0.2) {
|
||||
transform.value.scale = parseFloat((transform.value.scale - zoomRate).toFixed(3))
|
||||
}
|
||||
break
|
||||
case 'zoomIn':
|
||||
transform.value.scale = parseFloat((transform.value.scale + zoomRate).toFixed(3))
|
||||
break
|
||||
case 'clocelise':
|
||||
transform.value.deg += rotateDeg
|
||||
break
|
||||
case 'anticlocelise':
|
||||
transform.value.deg -= rotateDeg
|
||||
break
|
||||
}
|
||||
transform.value.enableTransition = enableTransition
|
||||
}
|
||||
|
||||
|
||||
watch(currentImg, () => {
|
||||
nextTick(() => {
|
||||
const $img = img.value
|
||||
if (!$img.complete) {
|
||||
loading.value = true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
watch(index, val => {
|
||||
reset()
|
||||
props.onSwitch(val)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
deviceSupportInstall()
|
||||
// add tabindex then wrapper can be focusable via Javascript
|
||||
// focus wrapper so arrow key can't cause inner scroll behavior underneath
|
||||
wrapper.value?.focus()
|
||||
})
|
||||
|
||||
return {
|
||||
index,
|
||||
wrapper,
|
||||
img,
|
||||
infinite: true,
|
||||
loading: false,
|
||||
isSingle,
|
||||
isFirst,
|
||||
isLast,
|
||||
currentImg,
|
||||
imgStyle,
|
||||
mode,
|
||||
handleActions,
|
||||
prev,
|
||||
next,
|
||||
hide,
|
||||
toggleMode,
|
||||
handleImgLoad,
|
||||
handleImgError,
|
||||
handleMouseDown,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
277
packages/image/src/index.vue
Normal file
277
packages/image/src/index.vue
Normal file
@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div ref="container" class="el-image">
|
||||
<slot v-if="loading" name="placeholder">
|
||||
<div class="el-image__placeholder"></div>
|
||||
</slot>
|
||||
<slot v-else-if="hasLoadError" name="error">
|
||||
<div class="el-image__error">{{ t('el.image.error') }}</div>
|
||||
</slot>
|
||||
<img
|
||||
v-else
|
||||
class="el-image__inner"
|
||||
v-bind="$attrs"
|
||||
:src="src"
|
||||
:style="imageStyle"
|
||||
:class="{ 'el-image__inner--center': alignCenter, 'el-image__preview': preview }"
|
||||
@click="clickHandler"
|
||||
>
|
||||
<template v-if="preview">
|
||||
<image-viewer
|
||||
v-if="showViewer"
|
||||
:z-index="zIndex"
|
||||
:initial-index="imageIndex"
|
||||
:on-close="closeViewer"
|
||||
:url-list="previewSrcList"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang='ts'>
|
||||
|
||||
import { defineComponent, computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { isString } from '@vue/shared'
|
||||
import throttle from 'lodash/throttle'
|
||||
import isServer from '@element-plus/utils/isServer'
|
||||
import { on, off, getScrollContainer, isInContainer } from '@element-plus/utils/dom'
|
||||
import { t } from '@element-plus/locale'
|
||||
import ImageViewer from './image-viewer'
|
||||
|
||||
const isSupportObjectFit = () => document.documentElement.style.objectFit !== undefined
|
||||
const isHtmlEle = e => e && e.nodeType === 1
|
||||
|
||||
const ObjectFit = {
|
||||
NONE: 'none',
|
||||
CONTAIN: 'contain',
|
||||
COVER: 'cover',
|
||||
FILL: 'fill',
|
||||
SCALE_DOWN: 'scale-down',
|
||||
}
|
||||
|
||||
let prevOverflow = ''
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ElImage',
|
||||
components: {
|
||||
ImageViewer,
|
||||
},
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
fit: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lazy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scrollContainer: {
|
||||
type: [String, Object],
|
||||
default: null,
|
||||
},
|
||||
previewSrcList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
zIndex: {
|
||||
type: Number,
|
||||
default: 2000,
|
||||
},
|
||||
},
|
||||
emits: ['error'],
|
||||
setup(props, { emit, attrs }) {
|
||||
// init here
|
||||
const hasLoadError = ref(false)
|
||||
const loading = ref(true)
|
||||
const imgWidth = ref(false)
|
||||
const imgHeight = ref(false)
|
||||
const showViewer = ref(false)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const show = ref(props.lazy)
|
||||
|
||||
let _scrollContainer = null
|
||||
let _lazyLoadHandler = null
|
||||
|
||||
const imageStyle = computed(() => {
|
||||
const { fit } = props
|
||||
if (!isServer && fit) {
|
||||
return isSupportObjectFit()
|
||||
? { 'object-fit': fit }
|
||||
: getImageStyle(fit)
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const alignCenter = computed(() => {
|
||||
const { fit } = props
|
||||
return !isServer && !isSupportObjectFit() && fit !== ObjectFit.FILL
|
||||
})
|
||||
|
||||
const preview = computed(() => {
|
||||
const { previewSrcList } = props
|
||||
return Array.isArray(previewSrcList) && previewSrcList.length > 0
|
||||
})
|
||||
const imageIndex = computed(() => {
|
||||
const { src , previewSrcList } = props
|
||||
let previewIndex = 0
|
||||
const srcIndex = previewSrcList.indexOf(src)
|
||||
if (srcIndex >= 0) {
|
||||
previewIndex = srcIndex
|
||||
}
|
||||
return previewIndex
|
||||
})
|
||||
|
||||
|
||||
function getImageStyle(fit) {
|
||||
const imageWidth = imgWidth.value
|
||||
const imageHeight = imgHeight.value
|
||||
|
||||
if (!container.value) return {}
|
||||
const {
|
||||
clientWidth: containerWidth,
|
||||
clientHeight: containerHeight,
|
||||
} = container.value
|
||||
if (!imageWidth || !imageHeight || !containerWidth || !containerHeight) return {}
|
||||
|
||||
const vertical = imageWidth / imageHeight < 1
|
||||
|
||||
if (fit === ObjectFit.SCALE_DOWN) {
|
||||
const isSmaller = imageWidth < containerWidth && imageHeight < containerHeight
|
||||
fit = isSmaller ? ObjectFit.NONE : ObjectFit.CONTAIN
|
||||
}
|
||||
|
||||
switch (fit) {
|
||||
case ObjectFit.NONE:
|
||||
return { width: 'auto', height: 'auto' }
|
||||
case ObjectFit.CONTAIN:
|
||||
return vertical ? { width: 'auto' } : { height: 'auto' }
|
||||
case ObjectFit.COVER:
|
||||
return vertical ? { height: 'auto' } : { width: 'auto' }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const loadImage = () => {
|
||||
if (isServer) return
|
||||
|
||||
// reset status
|
||||
loading.value = true
|
||||
hasLoadError.value = false
|
||||
|
||||
const img = new Image()
|
||||
img.onload = e => handleLoad(e, img)
|
||||
img.onerror = handleError
|
||||
|
||||
// bind html attrs
|
||||
// so it can behave consistently
|
||||
Object.keys(attrs)
|
||||
.forEach(key => {
|
||||
const value = attrs[key]
|
||||
img.setAttribute(key, value)
|
||||
})
|
||||
img.src = props.src
|
||||
}
|
||||
|
||||
function handleLoad(e: Event, img: Any) {
|
||||
imgWidth.value = img.width
|
||||
imgHeight.value = img.height
|
||||
loading.value = false
|
||||
hasLoadError.value = false
|
||||
}
|
||||
|
||||
function handleError(e: Event) {
|
||||
loading.value = false
|
||||
hasLoadError.value = true
|
||||
emit('error', e)
|
||||
}
|
||||
|
||||
function handleLazyLoad() {
|
||||
if (isInContainer(container.value, _scrollContainer)) {
|
||||
loadImage()
|
||||
removeLazyLoadListener()
|
||||
}
|
||||
}
|
||||
|
||||
function addLazyLoadListener() {
|
||||
if (isServer) return
|
||||
|
||||
const { scrollContainer } = props
|
||||
if (isHtmlEle(scrollContainer)) {
|
||||
_scrollContainer = scrollContainer
|
||||
} else if (isString(scrollContainer) && scrollContainer !== '') {
|
||||
_scrollContainer = document.querySelector(scrollContainer)
|
||||
} else {
|
||||
_scrollContainer = getScrollContainer(container.value)
|
||||
}
|
||||
if (_scrollContainer) {
|
||||
_lazyLoadHandler = throttle(handleLazyLoad, 200)
|
||||
on(_scrollContainer, 'scroll', _lazyLoadHandler)
|
||||
setTimeout(() => handleLazyLoad(), 100)
|
||||
}
|
||||
}
|
||||
|
||||
function removeLazyLoadListener() {
|
||||
if (isServer || !_scrollContainer || !_lazyLoadHandler) return
|
||||
|
||||
off(_scrollContainer, 'scroll', _lazyLoadHandler)
|
||||
_scrollContainer = null
|
||||
_lazyLoadHandler = null
|
||||
}
|
||||
|
||||
function clickHandler() {
|
||||
// don't show viewer when preview is false
|
||||
if (!preview.value) {
|
||||
return
|
||||
}
|
||||
// prevent body scroll
|
||||
prevOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
showViewer.value = true
|
||||
}
|
||||
|
||||
function closeViewer() {
|
||||
document.body.style.overflow = prevOverflow
|
||||
showViewer.value = false
|
||||
}
|
||||
|
||||
watch(() => props.src, () => {
|
||||
show.value && loadImage()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (props.lazy) {
|
||||
setTimeout(() => addLazyLoadListener(), 0)
|
||||
} else {
|
||||
loadImage()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
props.lazy && removeLazyLoadListener()
|
||||
})
|
||||
|
||||
return {
|
||||
loading,
|
||||
hasLoadError,
|
||||
showViewer,
|
||||
imgWidth,
|
||||
imgHeight,
|
||||
imageStyle,
|
||||
alignCenter,
|
||||
preview,
|
||||
imageIndex,
|
||||
clickHandler,
|
||||
closeViewer,
|
||||
container,
|
||||
t,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
@ -113,8 +113,10 @@ export const getStyle = function(
|
||||
styleName = 'cssFloat'
|
||||
}
|
||||
try {
|
||||
const style = element.style[styleName]
|
||||
if (style) return style
|
||||
const computed = document.defaultView.getComputedStyle(element, '')
|
||||
return element.style[styleName] || computed ? computed[styleName] : null
|
||||
return computed ? computed[styleName] : ''
|
||||
} catch (e) {
|
||||
return element.style[styleName]
|
||||
}
|
||||
@ -146,13 +148,12 @@ export const isScroll = (
|
||||
isVertical?: Nullable<boolean>,
|
||||
): RegExpMatchArray => {
|
||||
if (isServer) return
|
||||
|
||||
const determinedDirection = isVertical !== null || isVertical !== undefined
|
||||
const determinedDirection = isVertical === null || isVertical === undefined
|
||||
const overflow = determinedDirection
|
||||
? isVertical
|
||||
? getStyle(el, 'overflow')
|
||||
: isVertical
|
||||
? getStyle(el, 'overflow-y')
|
||||
: getStyle(el, 'overflow-x')
|
||||
: getStyle(el, 'overflow')
|
||||
|
||||
return overflow.match(/(scroll|auto)/)
|
||||
}
|
||||
@ -173,7 +174,6 @@ export const getScrollContainer = (
|
||||
}
|
||||
parent = parent.parentNode as HTMLElement
|
||||
}
|
||||
|
||||
return parent
|
||||
}
|
||||
|
||||
@ -200,7 +200,6 @@ export const isInContainer = (
|
||||
} else {
|
||||
containerRect = container.getBoundingClientRect()
|
||||
}
|
||||
|
||||
return (
|
||||
elRect.top < containerRect.bottom &&
|
||||
elRect.bottom > containerRect.top &&
|
||||
|
Loading…
Reference in New Issue
Block a user