From bc67af2901869a83ed6f4df3b27ccad0756757ba Mon Sep 17 00:00:00 2001
From: typistZxd <1046866318@qq.com>
Date: Thu, 6 Jan 2022 20:37:59 +0800
Subject: [PATCH] feat(comp:image): add image and imageViewer components
fix #698
---
.../components/config/src/defaultConfig.ts | 13 +-
packages/components/config/src/types.ts | 12 +-
.../__snapshots__/image.spec.ts.snap | 44 +--
.../__snapshots__/imageViewer.spec.ts.snap | 3 +
.../components/image/__tests__/image.spec.ts | 187 +++++-------
.../image/__tests__/imageViewer.spec.ts | 209 +++++++++++++
packages/components/image/demo/Basic.md | 8 +-
packages/components/image/demo/Basic.vue | 30 +-
packages/components/image/demo/Fallback.md | 9 -
packages/components/image/demo/Fallback.vue | 16 -
packages/components/image/demo/ImageAttrs.md | 14 +
packages/components/image/demo/ImageAttrs.vue | 14 +
packages/components/image/demo/ImageSlot.md | 14 +
packages/components/image/demo/ImageSlot.vue | 23 ++
packages/components/image/demo/ImageViewer.md | 14 +
.../components/image/demo/ImageViewer.vue | 18 ++
packages/components/image/demo/Preview.md | 12 +-
packages/components/image/demo/Preview.vue | 2 +-
packages/components/image/demo/Zoom.md | 14 +
packages/components/image/demo/Zoom.vue | 20 ++
packages/components/image/docs/Design.en.md | 3 +
packages/components/image/docs/Design.zh.md | 5 +-
packages/components/image/docs/Index.en.md | 29 ++
packages/components/image/docs/Index.zh.md | 35 ++-
packages/components/image/index.ts | 18 +-
packages/components/image/src/Image.tsx | 160 ++++++++++
packages/components/image/src/Image.vue | 93 ------
packages/components/image/src/ImageViewer.tsx | 43 +++
packages/components/image/src/ImgPreview.vue | 83 -----
.../src/component/ImageViewerContent.tsx | 286 ++++++++++++++++++
packages/components/image/src/types.ts | 51 +++-
packages/components/image/style/image.less | 78 +++++
packages/components/image/style/index.less | 75 -----
.../image/style/themes/default.less | 4 +-
.../components/image/style/themes/default.ts | 1 -
.../image/style/themes/default.variable.less | 31 +-
packages/components/image/style/viewer.less | 56 ++++
packages/components/index.ts | 3 +-
.../components/style/variable/prefix.less | 2 +-
packages/components/types.d.ts | 3 +-
packages/site/public/images/example/css.png | Bin 0 -> 14877 bytes
packages/site/public/images/example/html.png | Bin 0 -> 13313 bytes
packages/site/public/images/example/js.png | Bin 0 -> 44629 bytes
.../site/public/images/example/nodejs.png | Bin 0 -> 21808 bytes
44 files changed, 1231 insertions(+), 504 deletions(-)
create mode 100644 packages/components/image/__tests__/__snapshots__/imageViewer.spec.ts.snap
create mode 100644 packages/components/image/__tests__/imageViewer.spec.ts
delete mode 100644 packages/components/image/demo/Fallback.md
delete mode 100644 packages/components/image/demo/Fallback.vue
create mode 100644 packages/components/image/demo/ImageAttrs.md
create mode 100644 packages/components/image/demo/ImageAttrs.vue
create mode 100644 packages/components/image/demo/ImageSlot.md
create mode 100644 packages/components/image/demo/ImageSlot.vue
create mode 100644 packages/components/image/demo/ImageViewer.md
create mode 100644 packages/components/image/demo/ImageViewer.vue
create mode 100644 packages/components/image/demo/Zoom.md
create mode 100644 packages/components/image/demo/Zoom.vue
create mode 100644 packages/components/image/docs/Design.en.md
create mode 100644 packages/components/image/docs/Index.en.md
create mode 100644 packages/components/image/src/Image.tsx
delete mode 100644 packages/components/image/src/Image.vue
create mode 100644 packages/components/image/src/ImageViewer.tsx
delete mode 100644 packages/components/image/src/ImgPreview.vue
create mode 100644 packages/components/image/src/component/ImageViewerContent.tsx
create mode 100644 packages/components/image/style/image.less
delete mode 100644 packages/components/image/style/index.less
create mode 100644 packages/components/image/style/viewer.less
create mode 100644 packages/site/public/images/example/css.png
create mode 100644 packages/site/public/images/example/html.png
create mode 100644 packages/site/public/images/example/js.png
create mode 100644 packages/site/public/images/example/nodejs.png
diff --git a/packages/components/config/src/defaultConfig.ts b/packages/components/config/src/defaultConfig.ts
index e4caf21bb..f9aaea9a9 100644
--- a/packages/components/config/src/defaultConfig.ts
+++ b/packages/components/config/src/defaultConfig.ts
@@ -26,6 +26,7 @@ import type {
GlobalConfig,
IconConfig,
ImageConfig,
+ ImageViewerConfig,
InputConfig,
InputNumberConfig,
ListConfig,
@@ -232,10 +233,13 @@ const collapse: CollapseConfig = {
}
const image: ImageConfig = {
- width: 100,
- height: 100,
- fallback:
- 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1OCAoODQ2NjMpIC0gaHR0cHM6Ly9za2V0Y2guY29tIC0tPgogICAgPHRpdGxlPjEyODwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCB4MT0iNTAlIiB5MT0iMCUiIHgyPSI1MCUiIHkyPSI5OS42Mjc4NTA1JSIgaWQ9ImxpbmVhckdyYWRpZW50LTEiPgogICAgICAgICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjMDBBQ0ZGIiBvZmZzZXQ9IjAlIj48L3N0b3A+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiMzMzY2RkYiIG9mZnNldD0iMTAwJSI+PC9zdG9wPgogICAgICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICAgICAgPGxpbmVhckdyYWRpZW50IHgxPSI1MCUiIHkxPSIwJSIgeDI9IjUwJSIgeTI9IjEwMCUiIGlkPSJsaW5lYXJHcmFkaWVudC0yIj4KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzFEQjgzRiIgb2Zmc2V0PSIwJSI+PC9zdG9wPgogICAgICAgICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjNzJEMTNEIiBvZmZzZXQ9IjEwMCUiPjwvc3RvcD4KICAgICAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPC9kZWZzPgogICAgPGcgaWQ9IjEyOCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZD0iTTQ0LjQ2NzMxMjYsNjIuMjMyMTcwNSBMNzAuMTI5ODAxNSw4My43NjU1NTU0IEM2MC4xODk3NTEyLDk1LjYxMTY0NiA0Mi41Mjg1OTQ2LDk3LjE1Njc5NyAzMC42ODI1MDQsODcuMjE2NzQ2NyBMMjguMjQ4MTQ5Miw4NS4xNzM4OTc1IEMyMS4zMTIxMTA4LDc5LjMwODkyOTQgMTQuMzM1NDQzOSw3My40NTQ4NzE4IDcuMzE4MTQ4NDYsNjcuNjExNzI0NiBMOS4yNDY1MTEyOSw2NS4zMTM1OTEzIEMxMy44NDgzODY0LDU5LjgyOTI5MDEgMjAuMzAzNzUwNyw1Ni44MTc3Nzk2IDI2LjkyNzcxNzYsNTYuNDI2NDY4MyBMMjguMzQ5MTA1Nyw1Ni4zODI2OTk1IEMzNC4wNDAwNjg2LDU2LjM2ODg1MDQgMzkuNzY2NDgzLDU4LjI4NzcwNjEgNDQuNDY3MzEyNiw2Mi4yMzIxNzA1IFoiIGlkPSLot6/lvoQiIGZpbGw9IiMyMDRFRDkiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNOTUuNDM4MDk4NCw0NS4xMzQ1MDkgTDk3LjMzMTY2NTYsNDUuMjI3OTkzMiBMOTguNzAxMDU1OSw0NS4zNjI3MDcyIEwxMDAuNTE2MTI5LDQ1LjYyOTI5MTYgTDEwMS44OTEyODcsNDUuODk5NDE2NCBMMTAzLjQzNjU4NSw0Ni4yNzUwNzE5IEwxMDUuMTY5NDEsNDYuNzkwNDA1MSBMMTA2LjU4NzY2NSw0Ny4yODk1OTM3IEwxMDguMjEyNTY1LDQ3Ljk1MjAwNzQgTDEwOC45MzE3Niw0OC4yNzc2NDAxIEwxMDguOTMxNzYsNDguMjc3NjQwMSBMMTEwLjM1Mzg5OCw0OC45ODM2MzEgTDExMS44MDUxMjgsNDkuNzk0NjA0NSBMMTEzLjQ0MzE5OSw1MC44MzAxOTgxIEwxMTQuNjUzNDk2LDUxLjY4NTY5MTIgTDExNS44MjE1MjEsNTIuNTkyMTI3NSBMMTE3LjE4ODUzNyw1My43NjU3NjYgTDExOC40NzMyOTksNTQuOTk1MDg2NCBMMTE5LjUzMzEzLDU2LjExNTU4MDYgTDEyMC4wODg2Miw1Ni43NDY0NjgzIEwxMjAuMDg4NjIsNTYuNzQ2NDY4MyBMMTIxLjIwODAwMyw1OC4xMjIyMDA1IEwxMjIuMTMwNDY5LDU5LjM3NzQ1NiBMNzIuMjk3NDUzMyw5NC4yNzA5MDkyIEM2Ny41NzI1ODc4LDk3LjU3OTI5NTcgNjIuMTMzNTU1NCw5OS4zMTAyNzggNTYuNjY4NDkzMSw5OS40OTg0MDA0IEw1NS4wMjg4ODE3LDk5LjUwODU4NTggQzQ4LjQ3MjgxNzUsOTkuMzY0NDYwNyA0MS45NzgzNjIyLDk3LjAwMzU5MTYgMzYuNzM0NjE2NSw5Mi40ODU2NzA5IEwzMi43NjgwODE2LDg5LjAzMzg2MiBMMzIuNzY4MDgxNiw4OS4wMzM4NjIgTDI3LjYzNjUwMzksODQuNjQ2ODk0IEw3NS42Mzg0ODQ5LDUxLjE0MzQ2MzQgQzc3LjQ1OTY4NTEsNDkuODcyMzM5NyA3OS4zNjM0NjM3LDQ4LjgwNzg1NjQgODEuMzIxOTc2Miw0Ny45NDUwODIgTDgzLjI5NzM3NzksNDcuMTQ5NDc5OSBDODQuMzIzMzMxMSw0Ni43NzQwNzgyIDg1LjM2MTE5MzEsNDYuNDUxNzgxNSA4Ni40MDcyNTc2LDQ2LjE4MTg4MTUgTDg3Ljk4MjA0MzEsNDUuODE2MjQwMyBMODkuNjQ0MTg2Niw0NS41MTY1NyBMOTEuMzg1OTMwMyw0NS4yOTQ4NTUxIEw5My4wNTA0MjI4LDQ1LjE2OTM0MjMgQzkzLjg0NjY4MzIsNDUuMTI5Mjc1NyA5NC42NDMxMDY0LDQ1LjExNzc1ODggOTUuNDM4MDk4NCw0NS4xMzQ1MDkgWiIgaWQ9Iui3r+W+hCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudC0xKSI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik01MS4zODg4NTIsMzIuNDY5NDc1OSBMNjMuNjQ1NTYzMSw0Mi43NTQwNzc2IEM1OC42NzU1MzgsNDguNjc3MTIyOSA0OS44NDQ5NTk3LDQ5LjQ0OTY5ODQgNDMuOTIxOTE0NCw0NC40Nzk2NzMzIEwzMi44MTQyNywzNS4xNTkyNTMgTDMzLjc3ODQ1MTQsMzQuMDEwMTg2MyBDMzguMjE1OTczOCwyOC43MjE3NTMgNDYuMTAwNDE4NywyOC4wMzE5NTM0IDUxLjM4ODg1MiwzMi40Njk0NzU5IFoiIGlkPSLot6/lvoQiIGZpbGw9IiMwMzc4MkEiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNzIuMDY0NTA2MiwyNi4xMjg0NDA2IEw3My4yNjYzMDc5LDI2LjIyMjA0MTMgTDc0LjM3NzU0MTYsMjYuMzk3MDkgTDc1LjU3NjI3OTIsMjYuNjg0OTIyNCBMNzYuNDc4NTYyMywyNi45NzMxMDM2IEw3Ny41MTY0NTg5LDI3LjM4NjMzNDcgTDc3LjUxNjQ1ODksMjcuMzg2MzM0NyBMNzguMjAwNjU1MywyNy43MTA0NDczIEw3OS4zMDc1MTExLDI4LjMzMTQwODYgTDc5LjcwNDIsMjguNTg1OTgwNSBMODAuMjkxNTU3OCwyOC45OTc0NTY2IEw4MC44MDkzMjg3LDI5LjM5NzU4MjggTDgxLjIxODA3NjgsMjkuNzQwODE0MyBMODEuNzcyNzI0MywzMC4yNDk2ODA3IEw4Mi4yMTE2Mzk2LDMwLjY5MTk2MzIgTDgyLjIxMTYzOTYsMzAuNjkxOTYzMiBMODIuNjM0NzgxOCwzMS4xNTYxOTk0IEw4My4wOTIyMjcyLDMxLjcwNjIzMjEgTDgzLjYxOTAyNjMsMzIuNDEzNDQ1NSBMNjMuMTYzMzEwOCw0Ni43MzY2OTE3IEM2MC45ODg3ODEsNDguMjU5MzEzOSA1OC41MTE3ODE3LDQ5LjExMzY3NjggNTYuMDAwNjA2Myw0OS4zMTMzMzE2IEw1NC43NDM0MTI4LDQ5LjM1ODY2MjggQzUxLjM4OTkyNjQsNDkuMzM0Mzg5NyA0OC4wNTUxNzcxLDQ4LjE1MDIwNjEgNDUuMzc1MTE5NSw0NS44MzgyMzM0IEw0Mi4zNzc0OTk1LDQzLjI1MjMxMyBMNjMuMDcwNjgxNiwyOC43ODMyMDI2IEM2My42NzAxMjExLDI4LjM2NDA2MTggNjQuMjg5ODcxMywyNy45OTU2NDMzIDY0LjkyNDgzNTYsMjcuNjc3MDQ2OCBMNjUuOTUxNDUxMywyNy4yMTAyOTkxIEw2NS45NTE0NTEzLDI3LjIxMDI5OTEgTDY2LjU3MDYwOTYsMjYuOTczMjE1NyBMNjcuNzY3NzYyNywyNi42MDI1NDE2IEw2Ny43Njc3NjI3LDI2LjYwMjU0MTYgTDY4Ljk4MjU5NjQsMjYuMzM3MjA5MiBMNjkuNzk2ODcyMSwyNi4yMTg1ODE3IEw3MC4yMTY0MDg1LDI2LjE3NTQ3ODUgQzcwLjgzMjMzODgsMjYuMTIxMDkyMyA3MS40NDk2MzQ3LDI2LjEwNTYzNjEgNzIuMDY0NTA2MiwyNi4xMjg0NDA2IFoiIGlkPSLot6/lvoQiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQtMikiPjwvcGF0aD4KICAgIDwvZz4KPC9zdmc+',
+ preview: true,
+}
+
+const imageViewer: ImageViewerConfig = {
+ loop: true,
+ maskClosable: true,
+ zoom: [0.5, 2],
}
const statistic: StatisticConfig = {
@@ -413,6 +417,7 @@ export const defaultConfig: GlobalConfig = {
list,
collapse,
image,
+ imageViewer,
statistic,
table,
tooltip,
diff --git a/packages/components/config/src/types.ts b/packages/components/config/src/types.ts
index ddebefd6f..201acb571 100644
--- a/packages/components/config/src/types.ts
+++ b/packages/components/config/src/types.ts
@@ -226,9 +226,14 @@ export interface CollapseConfig {
}
export interface ImageConfig {
- width: string | number
- height: string | number
- fallback: string
+ preview: boolean
+}
+
+export interface ImageViewerConfig {
+ loop: boolean
+ maskClosable: boolean
+ zoom: number[]
+ target?: PortalTargetType
}
export interface NumFormatted {
@@ -436,6 +441,7 @@ export interface GlobalConfig {
list: ListConfig
collapse: CollapseConfig
image: ImageConfig
+ imageViewer: ImageViewerConfig
statistic: StatisticConfig
table: TableConfig
tooltip: TooltipConfig
diff --git a/packages/components/image/__tests__/__snapshots__/image.spec.ts.snap b/packages/components/image/__tests__/__snapshots__/image.spec.ts.snap
index 54061e86f..e253ee5b6 100644
--- a/packages/components/image/__tests__/__snapshots__/image.spec.ts.snap
+++ b/packages/components/image/__tests__/__snapshots__/image.spec.ts.snap
@@ -1,43 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Image render alt work 1`] = `
-"
-
-
"
-`;
-
-exports[`Image render height work 1`] = `
-"
-
-
"
-`;
-
-exports[`Image render objectFit work 1`] = `
-"
-
-
"
-`;
-
-exports[`Image render preview work 1`] = `
-"
-
-
"
-`;
-
-exports[`Image render src work 1`] = `
-"
-
-
"
-`;
-
-exports[`Image render width work 1`] = `
-"
-
-
"
-`;
-
exports[`Image render work 1`] = `
-"
-
+"
+
+
+
+
+
"
`;
diff --git a/packages/components/image/__tests__/__snapshots__/imageViewer.spec.ts.snap b/packages/components/image/__tests__/__snapshots__/imageViewer.spec.ts.snap
new file mode 100644
index 000000000..eebd6fd0d
--- /dev/null
+++ b/packages/components/image/__tests__/__snapshots__/imageViewer.spec.ts.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ImageViewer render work 1`] = `""`;
diff --git a/packages/components/image/__tests__/image.spec.ts b/packages/components/image/__tests__/image.spec.ts
index 7ad589c68..cfe97bbbf 100644
--- a/packages/components/image/__tests__/image.spec.ts
+++ b/packages/components/image/__tests__/image.spec.ts
@@ -1,130 +1,109 @@
-import { MountingOptions, mount } from '@vue/test-utils'
+import { MountingOptions, flushPromises, mount } from '@vue/test-utils'
+import { h } from 'vue'
import { renderWork } from '@tests'
-import IxImage from '../src/Image.vue'
+import Image from '../src/Image'
+import ImageViewer from '../src/ImageViewer'
import { ImageProps } from '../src/types'
describe('Image', () => {
- const ImageMount = (options?: MountingOptions
>) => mount(IxImage, { ...options })
+ const ImageMount = (options?: MountingOptions>) => {
+ const { props, ...rest } = options ?? {}
+ const src = '/icons/logo.svg'
- renderWork(IxImage)
+ return mount(Image, { ...({ ...rest, props: { src, ...props } } as MountingOptions) })
+ }
- test('render src work', async () => {
- const wrapper = ImageMount({
- props: {
- src: 'https://cdn.jsdelivr.net/gh/danranvm/image-hosting/images/idux.jpg',
- },
- })
- expect(wrapper.html()).toMatchSnapshot()
- await wrapper.setProps({ src: 'https://cdn.jsdelivr.net/gh/danranvm/image-hosting/images/vue.png' })
- })
- test('render width work', async () => {
- const wrapper = ImageMount({
- props: {
- width: '100px',
- src: 'https://cdn.jsdelivr.net/gh/danranvm/image-hosting/images/idux.jpg',
- },
- })
- expect(wrapper.html()).toMatchSnapshot()
- await wrapper.setProps({ width: '200px' })
- expect(wrapper.find('img').attributes()['style']).toMatch('width: 200px')
- await wrapper.setProps({ width: null })
+ renderWork(Image, { props: { src: '/icons/logo.svg' } })
- expect(wrapper.find('img').attributes()['style']).toMatch(`width: 100px`)
- })
- test('render height work', async () => {
- const wrapper = ImageMount({
- props: {
- height: '100px',
- src: 'https://cdn.jsdelivr.net/gh/danranvm/image-hosting/images/idux.jpg',
- },
- })
+ test('src work', async () => {
+ const testSrcA = '/a'
+ const testSrcB = '/b'
+ const wrapper = ImageMount({ props: { src: testSrcA } })
+ await flushPromises()
+
+ expect(wrapper.find('.ix-image-inner').attributes('src')).toBe(testSrcA)
+
+ await wrapper.setProps({ src: testSrcB })
- expect(wrapper.html()).toMatchSnapshot()
- await wrapper.setProps({ height: '200px' })
- expect(wrapper.find('img').attributes()['style']).toMatch('height: 200px')
- await wrapper.setProps({ height: null })
- expect(wrapper.find('img').attributes()['style']).toMatch('height: 100px')
+ expect(wrapper.find('.ix-image-inner').attributes('src')).toBe(testSrcB)
})
- test('render alt work', async () => {
- const wrapper = ImageMount({
- props: {
- alt: 'demo',
- src: 'https://cdn.jsdelivr.net/gh/danranvm/image-hosting/images/idux.jpg',
- },
- })
- expect(wrapper.html()).toMatchSnapshot()
- expect(wrapper.find('img').attributes()['alt']).toEqual('demo')
+ test('preview work', async () => {
+ const wrapper = ImageMount()
+ await flushPromises()
+ await wrapper.find('.ix-image-inner').trigger('load')
+
+ expect(wrapper.find('.ix-image-preview-wrapper').exists()).toBe(true)
+
+ await wrapper.find('.ix-image-preview-wrapper').trigger('click')
+
+ expect(wrapper.findComponent(ImageViewer).props('visible')).toBe(true)
+
+ await wrapper.setProps({ preview: false })
+
+ expect(wrapper.find('.ix-image-preview-wrapper').exists()).toBe(false)
})
- test('render objectFit work', async () => {
- const wrapper = ImageMount({
- props: {
- src: 'https://cdn.jsdelivr.net/gh/danranvm/image-hosting/images/idux.jpg',
- },
- })
- expect(wrapper.html()).toMatchSnapshot()
- await wrapper.setProps({ objectFit: 'fill' })
- expect(wrapper.find('img').attributes()['style']).toMatch('fill')
+ test('img attrs work', async () => {
+ const wrapper = ImageMount({ attrs: { alt: 'testAlt', width: '200' } })
+ await flushPromises()
+
+ expect(wrapper.find('.ix-image-inner').attributes('alt')).toBe('testAlt')
+ expect(wrapper.find('.ix-image-inner').attributes('width')).toBe('200')
})
- test('render preview work', async () => {
- const wrapper = ImageMount({
- props: {
- preview: true,
- width: 200,
- height: 200,
- src: 'https://cdn.jsdelivr.net/gh/danranvm/image-hosting/images/idux.jpg',
- },
- })
+ test('hooks work', async () => {
+ const onLoad = jest.fn()
+ const onError = jest.fn()
+ const wrapper = ImageMount({ props: { onLoad, onError } })
+ await flushPromises()
+ await wrapper.find('.ix-image-inner').trigger('load')
- expect(wrapper.html()).toMatchSnapshot()
- await wrapper.find('.ix-image-preview-is').trigger('click')
-
- expect(wrapper.find('.ix-image-preview').exists()).toBe(true)
- await wrapper.find('.ix-rotate-left').trigger('click')
- expect(wrapper.find('.ix-image-preview-img>img').attributes()['style']).toEqual(
- 'transform: scale3d(1, 1, 1) rotate(-90deg);',
- )
- await wrapper.find('.ix-rotate-right').trigger('click')
-
- expect(wrapper.find('.ix-image-preview-img>img').attributes()['style']).toEqual(
- 'transform: scale3d(1, 1, 1) rotate(0deg);',
- )
- await wrapper.find('.ix-zoom-in').trigger('click')
- expect(wrapper.find('.ix-image-preview-img>img').attributes()['style']).toEqual(
- 'transform: scale3d(1.1, 1.1, 1) rotate(0deg);',
- )
- await wrapper.find('.ix-zoom-out').trigger('click')
- expect(wrapper.find('.ix-image-preview-img>img').attributes()['style']).toEqual(
- 'transform: scale3d(1, 1, 1) rotate(0deg);',
- )
-
- let i = 10
- while (i) {
- await wrapper.find('.ix-zoom-out').trigger('click')
- i--
+ expect(onLoad).toBeCalled()
+
+ await wrapper.find('.ix-image-inner').trigger('error')
+
+ expect(onError).toBeCalled()
+ })
+
+ test('imageViewerProps work', async () => {
+ const imageViewer = {
+ visible: true,
+ activeIndex: 0,
+ images: ['/a'],
+ zoom: [1, 2],
+ loop: false,
+ maskClosable: false,
+ target: 'ix-image-container',
+ 'onUpdate:visible': () => {},
+ 'onUpdate:activeIndex': () => {},
}
- expect(wrapper.find('.ix-zoom-out').attributes()['class']).toEqual(
- 'ix-tools-item ix-zoom-out ix-tools-item-disabled',
- )
+ const wrapper = ImageMount({ props: { imageViewer } })
+ await flushPromises()
- await wrapper.find('.ix-close').trigger('click')
- expect(wrapper.find('ix-image-preview').exists()).toBe(false)
+ expect(wrapper.findComponent(ImageViewer).props()).toEqual(imageViewer)
})
- test('render fallback work', async () => {
- const fallback =
- 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iMTI4cHgiIGhlaWdodD0iMTI4cHgiIHZpZXdCb3g9IjAgMCAxMjggMTI4IiB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiPgogICAgPCEtLSBHZW5lcmF0b3I6IFNrZXRjaCA1OCAoODQ2NjMpIC0gaHR0cHM6Ly9za2V0Y2guY29tIC0tPgogICAgPHRpdGxlPjEyODwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPgogICAgICAgIDxsaW5lYXJHcmFkaWVudCB4MT0iNTAlIiB5MT0iMCUiIHgyPSI1MCUiIHkyPSI5OS42Mjc4NTA1JSIgaWQ9ImxpbmVhckdyYWRpZW50LTEiPgogICAgICAgICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjMDBBQ0ZGIiBvZmZzZXQ9IjAlIj48L3N0b3A+CiAgICAgICAgICAgIDxzdG9wIHN0b3AtY29sb3I9IiMzMzY2RkYiIG9mZnNldD0iMTAwJSI+PC9zdG9wPgogICAgICAgIDwvbGluZWFyR3JhZGllbnQ+CiAgICAgICAgPGxpbmVhckdyYWRpZW50IHgxPSI1MCUiIHkxPSIwJSIgeDI9IjUwJSIgeTI9IjEwMCUiIGlkPSJsaW5lYXJHcmFkaWVudC0yIj4KICAgICAgICAgICAgPHN0b3Agc3RvcC1jb2xvcj0iIzFEQjgzRiIgb2Zmc2V0PSIwJSI+PC9zdG9wPgogICAgICAgICAgICA8c3RvcCBzdG9wLWNvbG9yPSIjNzJEMTNEIiBvZmZzZXQ9IjEwMCUiPjwvc3RvcD4KICAgICAgICA8L2xpbmVhckdyYWRpZW50PgogICAgPC9kZWZzPgogICAgPGcgaWQ9IjEyOCIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPHBhdGggZD0iTTQ0LjQ2NzMxMjYsNjIuMjMyMTcwNSBMNzAuMTI5ODAxNSw4My43NjU1NTU0IEM2MC4xODk3NTEyLDk1LjYxMTY0NiA0Mi41Mjg1OTQ2LDk3LjE1Njc5NyAzMC42ODI1MDQsODcuMjE2NzQ2NyBMMjguMjQ4MTQ5Miw4NS4xNzM4OTc1IEMyMS4zMTIxMTA4LDc5LjMwODkyOTQgMTQuMzM1NDQzOSw3My40NTQ4NzE4IDcuMzE4MTQ4NDYsNjcuNjExNzI0NiBMOS4yNDY1MTEyOSw2NS4zMTM1OTEzIEMxMy44NDgzODY0LDU5LjgyOTI5MDEgMjAuMzAzNzUwNyw1Ni44MTc3Nzk2IDI2LjkyNzcxNzYsNTYuNDI2NDY4MyBMMjguMzQ5MTA1Nyw1Ni4zODI2OTk1IEMzNC4wNDAwNjg2LDU2LjM2ODg1MDQgMzkuNzY2NDgzLDU4LjI4NzcwNjEgNDQuNDY3MzEyNiw2Mi4yMzIxNzA1IFoiIGlkPSLot6/lvoQiIGZpbGw9IiMyMDRFRDkiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNOTUuNDM4MDk4NCw0NS4xMzQ1MDkgTDk3LjMzMTY2NTYsNDUuMjI3OTkzMiBMOTguNzAxMDU1OSw0NS4zNjI3MDcyIEwxMDAuNTE2MTI5LDQ1LjYyOTI5MTYgTDEwMS44OTEyODcsNDUuODk5NDE2NCBMMTAzLjQzNjU4NSw0Ni4yNzUwNzE5IEwxMDUuMTY5NDEsNDYuNzkwNDA1MSBMMTA2LjU4NzY2NSw0Ny4yODk1OTM3IEwxMDguMjEyNTY1LDQ3Ljk1MjAwNzQgTDEwOC45MzE3Niw0OC4yNzc2NDAxIEwxMDguOTMxNzYsNDguMjc3NjQwMSBMMTEwLjM1Mzg5OCw0OC45ODM2MzEgTDExMS44MDUxMjgsNDkuNzk0NjA0NSBMMTEzLjQ0MzE5OSw1MC44MzAxOTgxIEwxMTQuNjUzNDk2LDUxLjY4NTY5MTIgTDExNS44MjE1MjEsNTIuNTkyMTI3NSBMMTE3LjE4ODUzNyw1My43NjU3NjYgTDExOC40NzMyOTksNTQuOTk1MDg2NCBMMTE5LjUzMzEzLDU2LjExNTU4MDYgTDEyMC4wODg2Miw1Ni43NDY0NjgzIEwxMjAuMDg4NjIsNTYuNzQ2NDY4MyBMMTIxLjIwODAwMyw1OC4xMjIyMDA1IEwxMjIuMTMwNDY5LDU5LjM3NzQ1NiBMNzIuMjk3NDUzMyw5NC4yNzA5MDkyIEM2Ny41NzI1ODc4LDk3LjU3OTI5NTcgNjIuMTMzNTU1NCw5OS4zMTAyNzggNTYuNjY4NDkzMSw5OS40OTg0MDA0IEw1NS4wMjg4ODE3LDk5LjUwODU4NTggQzQ4LjQ3MjgxNzUsOTkuMzY0NDYwNyA0MS45NzgzNjIyLDk3LjAwMzU5MTYgMzYuNzM0NjE2NSw5Mi40ODU2NzA5IEwzMi43NjgwODE2LDg5LjAzMzg2MiBMMzIuNzY4MDgxNiw4OS4wMzM4NjIgTDI3LjYzNjUwMzksODQuNjQ2ODk0IEw3NS42Mzg0ODQ5LDUxLjE0MzQ2MzQgQzc3LjQ1OTY4NTEsNDkuODcyMzM5NyA3OS4zNjM0NjM3LDQ4LjgwNzg1NjQgODEuMzIxOTc2Miw0Ny45NDUwODIgTDgzLjI5NzM3NzksNDcuMTQ5NDc5OSBDODQuMzIzMzMxMSw0Ni43NzQwNzgyIDg1LjM2MTE5MzEsNDYuNDUxNzgxNSA4Ni40MDcyNTc2LDQ2LjE4MTg4MTUgTDg3Ljk4MjA0MzEsNDUuODE2MjQwMyBMODkuNjQ0MTg2Niw0NS41MTY1NyBMOTEuMzg1OTMwMyw0NS4yOTQ4NTUxIEw5My4wNTA0MjI4LDQ1LjE2OTM0MjMgQzkzLjg0NjY4MzIsNDUuMTI5Mjc1NyA5NC42NDMxMDY0LDQ1LjExNzc1ODggOTUuNDM4MDk4NCw0NS4xMzQ1MDkgWiIgaWQ9Iui3r+W+hCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudC0xKSI+PC9wYXRoPgogICAgICAgIDxwYXRoIGQ9Ik01MS4zODg4NTIsMzIuNDY5NDc1OSBMNjMuNjQ1NTYzMSw0Mi43NTQwNzc2IEM1OC42NzU1MzgsNDguNjc3MTIyOSA0OS44NDQ5NTk3LDQ5LjQ0OTY5ODQgNDMuOTIxOTE0NCw0NC40Nzk2NzMzIEwzMi44MTQyNywzNS4xNTkyNTMgTDMzLjc3ODQ1MTQsMzQuMDEwMTg2MyBDMzguMjE1OTczOCwyOC43MjE3NTMgNDYuMTAwNDE4NywyOC4wMzE5NTM0IDUxLjM4ODg1MiwzMi40Njk0NzU5IFoiIGlkPSLot6/lvoQiIGZpbGw9IiMwMzc4MkEiPjwvcGF0aD4KICAgICAgICA8cGF0aCBkPSJNNzIuMDY0NTA2MiwyNi4xMjg0NDA2IEw3My4yNjYzMDc5LDI2LjIyMjA0MTMgTDc0LjM3NzU0MTYsMjYuMzk3MDkgTDc1LjU3NjI3OTIsMjYuNjg0OTIyNCBMNzYuNDc4NTYyMywyNi45NzMxMDM2IEw3Ny41MTY0NTg5LDI3LjM4NjMzNDcgTDc3LjUxNjQ1ODksMjcuMzg2MzM0NyBMNzguMjAwNjU1MywyNy43MTA0NDczIEw3OS4zMDc1MTExLDI4LjMzMTQwODYgTDc5LjcwNDIsMjguNTg1OTgwNSBMODAuMjkxNTU3OCwyOC45OTc0NTY2IEw4MC44MDkzMjg3LDI5LjM5NzU4MjggTDgxLjIxODA3NjgsMjkuNzQwODE0MyBMODEuNzcyNzI0MywzMC4yNDk2ODA3IEw4Mi4yMTE2Mzk2LDMwLjY5MTk2MzIgTDgyLjIxMTYzOTYsMzAuNjkxOTYzMiBMODIuNjM0NzgxOCwzMS4xNTYxOTk0IEw4My4wOTIyMjcyLDMxLjcwNjIzMjEgTDgzLjYxOTAyNjMsMzIuNDEzNDQ1NSBMNjMuMTYzMzEwOCw0Ni43MzY2OTE3IEM2MC45ODg3ODEsNDguMjU5MzEzOSA1OC41MTE3ODE3LDQ5LjExMzY3NjggNTYuMDAwNjA2Myw0OS4zMTMzMzE2IEw1NC43NDM0MTI4LDQ5LjM1ODY2MjggQzUxLjM4OTkyNjQsNDkuMzM0Mzg5NyA0OC4wNTUxNzcxLDQ4LjE1MDIwNjEgNDUuMzc1MTE5NSw0NS44MzgyMzM0IEw0Mi4zNzc0OTk1LDQzLjI1MjMxMyBMNjMuMDcwNjgxNiwyOC43ODMyMDI2IEM2My42NzAxMjExLDI4LjM2NDA2MTggNjQuMjg5ODcxMywyNy45OTU2NDMzIDY0LjkyNDgzNTYsMjcuNjc3MDQ2OCBMNjUuOTUxNDUxMywyNy4yMTAyOTkxIEw2NS45NTE0NTEzLDI3LjIxMDI5OTEgTDY2LjU3MDYwOTYsMjYuOTczMjE1NyBMNjcuNzY3NzYyNywyNi42MDI1NDE2IEw2Ny43Njc3NjI3LDI2LjYwMjU0MTYgTDY4Ljk4MjU5NjQsMjYuMzM3MjA5MiBMNjkuNzk2ODcyMSwyNi4yMTg1ODE3IEw3MC4yMTY0MDg1LDI2LjE3NTQ3ODUgQzcwLjgzMjMzODgsMjYuMTIxMDkyMyA3MS40NDk2MzQ3LDI2LjEwNTYzNjEgNzIuMDY0NTA2MiwyNi4xMjg0NDA2IFoiIGlkPSLot6/lvoQiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQtMikiPjwvcGF0aD4KICAgIDwvZz4KPC9zdmc+'
+
+ test('slots work', async () => {
const wrapper = ImageMount({
- props: {
- fallback,
- src: '',
+ slots: {
+ previewIcon: h('div', { class: 'slot-previewIcon' }),
+ placeholder: h('div', { class: 'slot-placeholder' }),
+ fallback: h('div', { class: 'slot-fallback' }),
},
})
- await wrapper.find('img').trigger('error')
+ await flushPromises()
+
+ expect(wrapper.find('.slot-placeholder').exists()).toBe(true)
+
+ await wrapper.find('.ix-image-inner').trigger('load')
+
+ expect(wrapper.find('.slot-previewIcon').exists()).toBe(true)
+
+ await wrapper.find('.ix-image-inner').trigger('error')
- expect(wrapper.find('.ix-image-error').isVisible()).toBe(true)
+ expect(wrapper.find('.slot-fallback').exists()).toBe(true)
})
})
diff --git a/packages/components/image/__tests__/imageViewer.spec.ts b/packages/components/image/__tests__/imageViewer.spec.ts
new file mode 100644
index 000000000..bb65ec3c7
--- /dev/null
+++ b/packages/components/image/__tests__/imageViewer.spec.ts
@@ -0,0 +1,209 @@
+import type { ImageViewerProps } from '../src/types'
+import type { MountingOptions } from '@vue/test-utils'
+
+import { flushPromises, mount } from '@vue/test-utils'
+
+import { renderWork, wait } from '@tests'
+
+import ImageViewer from '../src/ImageViewer'
+import ImageViewerContent from '../src/component/ImageViewerContent'
+
+describe('ImageViewer', () => {
+ beforeEach(() => {
+ const el = document.createElement('div')
+ el.className = 'ix-image-viewer-container'
+ document.body.appendChild(el)
+ })
+
+ afterEach(() => {
+ ;(document.querySelector('.ix-image-viewer-container') as HTMLElement).innerHTML = ''
+ })
+
+ const ImageViewerMount = (options: MountingOptions> = {}) => {
+ const { props = {}, ...rest } = options
+ const images = ['/icons/logo.svg']
+ const activeIndex = 0
+ return mount(ImageViewer, {
+ ...({
+ ...rest,
+ props: { images, activeIndex, ...props },
+ } as MountingOptions),
+ })
+ }
+
+ renderWork(ImageViewer, { props: { images: ['/icons/logo.svg'] } })
+
+ test('v-model:visible work', async () => {
+ const onUpdateVisible = jest.fn()
+ const wrapper = ImageViewerMount({ props: { visible: false, 'onUpdate:visible': onUpdateVisible } })
+ await flushPromises()
+
+ expect(wrapper.findComponent(ImageViewerContent).exists()).toBe(false)
+
+ await wrapper.setProps({ visible: true })
+
+ expect(wrapper.findComponent(ImageViewerContent).exists()).toBe(true)
+
+ await wrapper.findComponent(ImageViewerContent).find('img').trigger('click')
+
+ expect(onUpdateVisible).toBeCalledWith(false)
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-close').trigger('click')
+
+ expect(onUpdateVisible).toBeCalledWith(false)
+
+ await wrapper.findComponent(ImageViewerContent).trigger('keydown.esc')
+
+ expect(onUpdateVisible).toBeCalledWith(false)
+ })
+
+ test('v-model:activeIndex work', async () => {
+ const onUpdateActiveIndex = jest.fn()
+ const images = ['/1.png', '/2.png', '/3.png']
+ const wrapper = ImageViewerMount({
+ props: { images, visible: true, activeIndex: 0, 'onUpdate:activeIndex': onUpdateActiveIndex },
+ })
+ await flushPromises()
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('src')).toBe(images[0])
+
+ await wrapper.setProps({ activeIndex: 1 })
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('src')).toBe(images[1])
+
+ await wrapper.findComponent(ImageViewerContent).trigger('keydown', { code: 'ArrowRight' })
+ await wait(10) // debounce
+
+ expect(onUpdateActiveIndex).toBeCalledWith(2)
+
+ await wrapper.findComponent(ImageViewerContent).trigger('keydown', { code: 'ArrowLeft' })
+ await wait(10) // debounce
+
+ expect(onUpdateActiveIndex).toBeCalledWith(0)
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-left').trigger('click')
+ await wait(10) // debounce
+
+ expect(onUpdateActiveIndex).toBeCalledWith(0)
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-right').trigger('click')
+ await wait(10) // debounce
+
+ expect(onUpdateActiveIndex).toBeCalledWith(2)
+ })
+
+ test('images work', async () => {
+ const imagesOld = ['/1.png']
+ const wrapper = ImageViewerMount({
+ props: { images: imagesOld, visible: true, activeIndex: 0 },
+ })
+ await flushPromises()
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('src')).toBe(imagesOld[0])
+
+ const imageNew = ['/2.png']
+ await wrapper.setProps({ images: imageNew })
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('src')).toBe(imageNew[0])
+ })
+
+ test('zoom work', async () => {
+ const wrapper = ImageViewerMount({ props: { visible: true } })
+ await flushPromises()
+
+ await wrapper.findComponent(ImageViewerContent).trigger('mousewheel', { wheelDelta: 10 })
+ await wait(10) // debounce
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(1.2)')
+
+ await wrapper.findComponent(ImageViewerContent).trigger('mousewheel', { wheelDelta: -10 })
+ await wait(10) // debounce
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(1)')
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-zoom-in').trigger('click')
+ await wait(10) // debounce
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(1.2)')
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-zoom-out').trigger('click')
+ await wait(10) // debounce
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(1)')
+
+ await wrapper.setProps({ zoom: [0.8, 0.9] })
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(0.9)')
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-zoom-in').trigger('click')
+ await wait(10) // debounce
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(0.9)')
+
+ await wrapper.setProps({ zoom: [1.1, 1.2] })
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(1.1)')
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-zoom-out').trigger('click')
+ await wait(10) // debounce
+
+ expect(wrapper.findComponent(ImageViewerContent).find('img').attributes('style')).toMatch('scale(1.1)')
+ })
+
+ test('loop work', async () => {
+ const images = ['/1.png', '/2.png', '/3.png']
+ const onUpdateActiveIndex = jest.fn()
+ const wrapper = ImageViewerMount({
+ props: { images, visible: true, activeIndex: 0, 'onUpdate:activeIndex': onUpdateActiveIndex },
+ })
+ await flushPromises()
+
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-left').trigger('click')
+ await wait(10) // debounce
+
+ expect(onUpdateActiveIndex).toBeCalledWith(2)
+
+ await wrapper.setProps({ activeIndex: 2 })
+ await wrapper.findComponent(ImageViewerContent).find('.ix-icon-right').trigger('click')
+ await wait(10) // debounce
+
+ expect(onUpdateActiveIndex).toBeCalledWith(0)
+
+ await wrapper.setProps({ loop: false })
+
+ expect(wrapper.findComponent(ImageViewerContent).find('.ix-icon-right').classes().toString()).toMatch('disabled')
+
+ await wrapper.setProps({ activeIndex: 0 })
+
+ expect(wrapper.findComponent(ImageViewerContent).find('.ix-icon-left').classes().toString()).toMatch('disabled')
+ })
+
+ test('maskClosable work', async () => {
+ const onUpdateVisible = jest.fn()
+ const wrapper = ImageViewerMount({
+ props: { visible: true, 'onUpdate:visible': onUpdateVisible },
+ })
+ await wrapper.findComponent(ImageViewerContent).find('img').trigger('click')
+
+ expect(onUpdateVisible).toBeCalledWith(false)
+ onUpdateVisible.mockRestore()
+
+ await wrapper.setProps({ maskClosable: false })
+ await wrapper.findComponent(ImageViewerContent).find('.ix-image-viewer-preview').trigger('click')
+
+ expect(onUpdateVisible).not.toBeCalled()
+ })
+
+ test('target work', async () => {
+ const wrapper = ImageViewerMount({ props: { visible: true } })
+ await flushPromises()
+
+ expect((document.querySelector('.ix-image-viewer-container .ix-image-viewer') as HTMLElement).innerHTML).not.toBe(
+ '',
+ )
+
+ await wrapper.setProps({ target: 'image-viewer-container' })
+
+ expect((document.querySelector('.image-viewer-container .ix-image-viewer') as HTMLElement).innerHTML).not.toBe('')
+ })
+})
diff --git a/packages/components/image/demo/Basic.md b/packages/components/image/demo/Basic.md
index 601253ba3..6887d94f7 100644
--- a/packages/components/image/demo/Basic.md
+++ b/packages/components/image/demo/Basic.md
@@ -2,9 +2,13 @@
order: 0
title:
zh: 基本使用
-
+ en: Basic usage
---
## zh
-可通过`fit`确定图片如何适应到容器框,同原生 [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-objectFit)
+最简单的用法,`src`是必须的。
+
+## en
+
+The simplest usage, `src` is required.
diff --git a/packages/components/image/demo/Basic.vue b/packages/components/image/demo/Basic.vue
index 5dda5ba2d..b50c999f0 100644
--- a/packages/components/image/demo/Basic.vue
+++ b/packages/components/image/demo/Basic.vue
@@ -1,31 +1,3 @@
-
-
-
-
-
-
-
-
+
-
-
-
-
diff --git a/packages/components/image/demo/Fallback.md b/packages/components/image/demo/Fallback.md
deleted file mode 100644
index 30ef1675c..000000000
--- a/packages/components/image/demo/Fallback.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-order: 0
-title:
- zh: 容错处理
-
----
-## zh
-
-通过设置`fallback`,当图片加载失败的时候会显示此占位内容
diff --git a/packages/components/image/demo/Fallback.vue b/packages/components/image/demo/Fallback.vue
deleted file mode 100644
index df0a7fff1..000000000
--- a/packages/components/image/demo/Fallback.vue
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
diff --git a/packages/components/image/demo/ImageAttrs.md b/packages/components/image/demo/ImageAttrs.md
new file mode 100644
index 000000000..d7b822b76
--- /dev/null
+++ b/packages/components/image/demo/ImageAttrs.md
@@ -0,0 +1,14 @@
+---
+order: 1
+title:
+ zh: img标签属性
+ en: Img tag attributes
+---
+
+## zh
+
+继承所有 [img标签属性](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img)。
+
+## en
+
+Inherit all [img tag attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img).
diff --git a/packages/components/image/demo/ImageAttrs.vue b/packages/components/image/demo/ImageAttrs.vue
new file mode 100644
index 000000000..25ab074d6
--- /dev/null
+++ b/packages/components/image/demo/ImageAttrs.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/packages/components/image/demo/ImageSlot.md b/packages/components/image/demo/ImageSlot.md
new file mode 100644
index 000000000..a759f3207
--- /dev/null
+++ b/packages/components/image/demo/ImageSlot.md
@@ -0,0 +1,14 @@
+---
+order: 3
+title:
+ zh: 自定义插槽展示
+ en: Slots
+---
+
+## zh
+
+支持自定义预览图标 `previewIcon`、加载中展示 `placeholder`、加载失败展示 `fallback`。
+
+## en
+
+Support custom preview icon `previewIcon`, loading display `placeholder`, loading failure display `fallback`.
diff --git a/packages/components/image/demo/ImageSlot.vue b/packages/components/image/demo/ImageSlot.vue
new file mode 100644
index 000000000..cb4532603
--- /dev/null
+++ b/packages/components/image/demo/ImageSlot.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ Failed!
+
+
+ loading...
+
+
+
+
+
diff --git a/packages/components/image/demo/ImageViewer.md b/packages/components/image/demo/ImageViewer.md
new file mode 100644
index 000000000..4785e64bd
--- /dev/null
+++ b/packages/components/image/demo/ImageViewer.md
@@ -0,0 +1,14 @@
+---
+order: 4
+title:
+ zh: 预览组件
+ en: ImageViewer
+---
+
+## zh
+
+支持单独使用预览组件`IxImageViewer`,`images`是必须的。
+
+## en
+
+Supports separate use of the preview component `IxImageViewer`, `images` is a must.
diff --git a/packages/components/image/demo/ImageViewer.vue b/packages/components/image/demo/ImageViewer.vue
new file mode 100644
index 000000000..bbe577a1f
--- /dev/null
+++ b/packages/components/image/demo/ImageViewer.vue
@@ -0,0 +1,18 @@
+
+ click me
+
+
+
+
diff --git a/packages/components/image/demo/Preview.md b/packages/components/image/demo/Preview.md
index b9463a20b..c2baf7ba8 100644
--- a/packages/components/image/demo/Preview.md
+++ b/packages/components/image/demo/Preview.md
@@ -1,10 +1,14 @@
---
-order: 0
+order: 2
title:
- zh: 预览大图
-
+ zh: 图片预览
+ en: Preview
---
## zh
-通过设置`preview`为`true`,使成功加载的图片有点击预览大图的功能
+已内置预览组件`IxImageViewer`,使用 `preview` 控制是否开启图片预览,默认开启,可通过 `imageViewer` 配置预览组件,预览组件配置同 [imageViewerProps](/components/image/zh#ImageViewerProps) 。
+
+## en
+
+Has built-in preview component `IxImageViewer`, use `preview` to control whether to open the image preview, it is enabled by default, and the preview component can be configured through `imageViewer`. The preview component props is the same as [imageViewerProps](/components/image/zh#ImageViewerProps).
diff --git a/packages/components/image/demo/Preview.vue b/packages/components/image/demo/Preview.vue
index d73d93ff6..204900728 100644
--- a/packages/components/image/demo/Preview.vue
+++ b/packages/components/image/demo/Preview.vue
@@ -1,3 +1,3 @@
-
+
diff --git a/packages/components/image/demo/Zoom.md b/packages/components/image/demo/Zoom.md
new file mode 100644
index 000000000..71e8d345e
--- /dev/null
+++ b/packages/components/image/demo/Zoom.md
@@ -0,0 +1,14 @@
+---
+order: 5
+title:
+ zh: 缩放
+ en: Zoom
+---
+
+## zh
+
+支持预览图的缩放范围,使用 `zoom` ,默认为 `[0.5, 2]`,表示最小展示0.5倍尺寸,最大展示2倍。
+
+## en
+
+Support the zoom range of the preview image, use `zoom`, the default is `[0.5, 2]`, which means that the minimum display size is 0.5 times, and the maximum display size is 2 times.
diff --git a/packages/components/image/demo/Zoom.vue b/packages/components/image/demo/Zoom.vue
new file mode 100644
index 000000000..c674456b6
--- /dev/null
+++ b/packages/components/image/demo/Zoom.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+ click me
+
+
+
+
diff --git a/packages/components/image/docs/Design.en.md b/packages/components/image/docs/Design.en.md
new file mode 100644
index 000000000..d1e713d5e
--- /dev/null
+++ b/packages/components/image/docs/Design.en.md
@@ -0,0 +1,3 @@
+## Description
+
+## Usage scenarios
diff --git a/packages/components/image/docs/Design.zh.md b/packages/components/image/docs/Design.zh.md
index 0aab31037..933801767 100644
--- a/packages/components/image/docs/Design.zh.md
+++ b/packages/components/image/docs/Design.zh.md
@@ -1,4 +1,3 @@
-## 何时使用
+## 组件定义
-- 需要展示图片时使用。
-- 加载大图时显示或加载失败时容错处理。
+## 使用场景
diff --git a/packages/components/image/docs/Index.en.md b/packages/components/image/docs/Index.en.md
new file mode 100644
index 000000000..5c62d0349
--- /dev/null
+++ b/packages/components/image/docs/Index.en.md
@@ -0,0 +1,29 @@
+---
+category: components
+type: Data Display
+order: 0
+title: Image
+subtitle:
+---
+
+## API
+
+### IxImage
+
+#### ImageProps
+
+| Name | Description | Type | Default | Global Config | Remark |
+| --- | --- | --- | --- | --- | --- |
+| - | - | - | - | ✅ | - |
+
+#### ImageSlots
+
+| Name | Description | Parameter Type | Remark |
+| --- | --- | --- | --- |
+| - | - | - | - |
+
+#### ImageMethods
+
+| Name | Description | Parameter Type | Remark |
+| --- | --- | --- | --- |
+| - | - | - | - |
diff --git a/packages/components/image/docs/Index.zh.md b/packages/components/image/docs/Index.zh.md
index b6cbe4c91..dcd0762f4 100644
--- a/packages/components/image/docs/Index.zh.md
+++ b/packages/components/image/docs/Index.zh.md
@@ -1,6 +1,7 @@
---
category: components
type: 数据展示
+order: 0
title: Image
subtitle: 图片
---
@@ -9,20 +10,38 @@ subtitle: 图片
### IxImage
+图片组件
+
#### ImageProps
+> 继承所有 [img标签属性](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img)
+
| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 |
| --- | --- | --- | --- | --- | --- |
| `src` | 图片地址 | `string` | - | - | - |
-| `width` | 图像宽度 | `string \| number` | - | ✅ | - |
-| `height` | 图像高度 | `string \| number` | - | ✅ | - |
-| `fallback` | 加载失败容错地址 | `string` | - | ✅ | - |
-| `preview` | 预览参数,为 `false` 时禁用 | `boolean` | - | - | - |
-| `alt` | 图像描述 | `string` | - | - | - |
-| `objectFit` | 确定图片如何适应容器框 | `string` | - | - | 同原生 [object-fit](https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit) |
+| `preview` | 是否开启预览 | `boolean` | `true` | ✅ | - |
+| `imageViewer` | 预览组件`IxImageViewer`的配置 | `ImageViewerProps` | `{}` | - | - |
-#### ImageEmits
+#### ImageSlots
| 名称 | 说明 | 参数类型 | 备注 |
| --- | --- | --- | --- |
-| `statusChange` | 图片加载状态改变时触发 | `loading\|loaded\|failed` | - |
+| `previewIcon` | 预览的icon | - | - |
+| `placeholder` | 图片未加载的占位内容 | - | - |
+| `fallback` | 加载失败时展示内容 | - | - |
+
+### IxImageViewer
+
+图片预览组件
+
+#### ImageViewerProps
+
+| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 |
+| --- | --- | --- | --- | --- | --- |
+| `v-model:visible` | 是否可见 | `boolean` | `false` | - | - |
+| `v-model:activeIndex` | 当前激活的索引 | `number` | `0` | - | - |
+| `images` | 用于预览的图片链接列表,必选 | `string[]` | `[]` | - | - |
+| `loop` | 是否无限循环 | `boolean` | `true` | ✅ | - |
+| `zoom` | 可缩放的倍数范围 | `number[]` | `[0.5, 2]` | ✅ | - |
+| `target` | 预览窗口容器节点 | `string \| HTMLElement \| () => string \| HTMLElement` | - | ✅ | - |
+| `maskClosable` | 是否可以通过点击遮罩层关闭预览 | `boolean` | `true` | ✅ | - |
diff --git a/packages/components/image/index.ts b/packages/components/image/index.ts
index d7a2d2619..fdf949a3b 100644
--- a/packages/components/image/index.ts
+++ b/packages/components/image/index.ts
@@ -5,12 +5,22 @@
* found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
*/
-import type { ImageComponent } from './src/types'
+import type { ImageComponent, ImageViewerComponent } from './src/types'
-import Image from './src/Image.vue'
+import Image from './src/Image'
+import ImageViewer from './src/ImageViewer'
const IxImage = Image as unknown as ImageComponent
+const IxImageViewer = ImageViewer as unknown as ImageViewerComponent
-export { IxImage }
+export { IxImage, IxImageViewer }
-export type { ImageInstance, ImageComponent, ImagePublicProps as ImageProps, ImageStatus } from './src/types'
+export type {
+ ImageStatus,
+ ImageInstance,
+ ImageComponent,
+ ImagePublicProps as ImageProps,
+ ImageViewerInstance,
+ ImageViewerComponent,
+ ImageViewerPublicProps as ImageViewerProps,
+} from './src/types'
diff --git a/packages/components/image/src/Image.tsx b/packages/components/image/src/Image.tsx
new file mode 100644
index 000000000..3481bd874
--- /dev/null
+++ b/packages/components/image/src/Image.tsx
@@ -0,0 +1,160 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import type { ImageProps, ImageStatus } from './types'
+import type { ImageConfig } from '@idux/components/config'
+import type { CSSProperties, ComputedRef, Ref, Slots } from 'vue'
+
+import { computed, defineComponent, normalizeClass, ref, watch } from 'vue'
+
+import { callEmit } from '@idux/cdk/utils'
+import { useGlobalConfig } from '@idux/components/config'
+import { IxIcon } from '@idux/components/icon'
+
+import ImageViewer from './ImageViewer'
+import { imageProps } from './types'
+
+export default defineComponent({
+ name: 'IxImage',
+ inheritAttrs: false,
+ props: imageProps,
+ setup(props, { attrs, slots }) {
+ const { class: className, style, ...rest } = attrs
+ const common = useGlobalConfig('common')
+ const mergedPrefixCls = computed(() => `${common.prefixCls}-image`)
+ const config = useGlobalConfig('image')
+ const preview = usePreview(props, config)
+ const { status, setFailed, setLoaded } = useStatus(props)
+ const [viewerVisible, setVisible] = useViewerVisible()
+ const { outerClasses, overLayerClasses, imageClasses } = useClasses(
+ mergedPrefixCls,
+ className as string,
+ status,
+ preview,
+ )
+
+ return () => {
+ const imageViewerProps = {
+ visible: viewerVisible.value,
+ 'onUpdate:visible': setVisible,
+ images: [props.src],
+ ...(props.imageViewer ?? {}),
+ }
+
+ return (
+
+
+ {renderPreviewIcon(props, slots, mergedPrefixCls, status, preview, setVisible)}
+ {renderPlaceholder(slots, mergedPrefixCls, status)}
+ {renderFallback(slots, mergedPrefixCls, status)}
+
+
+
+
+ )
+ }
+ },
+})
+
+function renderPreviewIcon(
+ props: ImageProps,
+ slots: Slots,
+ mergedPrefixCls: ComputedRef,
+ status: Ref,
+ preview: ComputedRef,
+ setVisible: (visible: boolean) => void,
+) {
+ return (
+ status.value === 'loaded' &&
+ preview.value && (
+ setVisible(true)}>
+ {slots.previewIcon?.() ?? }
+
+ )
+ )
+}
+
+function renderPlaceholder(slots: Slots, mergedPrefixCls: ComputedRef, status: Ref) {
+ return (
+ status.value === 'loading' &&
+ (slots.placeholder?.() ?? )
+ )
+}
+
+function renderFallback(slots: Slots, mergedPrefixCls: ComputedRef, status: Ref) {
+ return (
+ status.value === 'failed' &&
+ (slots.fallback?.() ?? )
+ )
+}
+
+function useViewerVisible(): [Ref, (visible: boolean) => void] {
+ const viewerVisible = ref(false)
+ const setVisible = (visible: boolean) => {
+ viewerVisible.value = visible
+ }
+ return [viewerVisible, setVisible]
+}
+
+function useClasses(
+ mergedPrefixCls: ComputedRef,
+ className: string,
+ status: Ref,
+ preview: ComputedRef,
+) {
+ const outerClasses = computed(() =>
+ normalizeClass([
+ mergedPrefixCls.value,
+ className,
+ `${mergedPrefixCls.value}-${status.value}`,
+ { [`${mergedPrefixCls.value}-preview`]: preview.value },
+ ]),
+ )
+ const overLayerClasses = computed(() => normalizeClass(`${mergedPrefixCls.value}-layer`))
+ const imageClasses = computed(() =>
+ normalizeClass([
+ `${mergedPrefixCls.value}-inner`,
+ { [`${mergedPrefixCls.value}-inner-hidden`]: status.value !== 'loaded' },
+ ]),
+ )
+
+ return {
+ outerClasses,
+ overLayerClasses,
+ imageClasses,
+ }
+}
+
+function usePreview(props: ImageProps, config: ImageConfig) {
+ return computed(() => props.preview ?? config.preview)
+}
+
+function useStatus(props: ImageProps) {
+ const status: Ref = ref('loading')
+ const setLoaded = (e: Event) => {
+ status.value = 'loaded'
+ callEmit(props.onLoad, e)
+ }
+ const setFailed = (e: Event) => {
+ status.value = 'failed'
+ callEmit(props.onError, e)
+ }
+
+ watch(
+ () => props.src,
+ () => {
+ status.value = 'loading'
+ },
+ { immediate: true },
+ )
+
+ return {
+ status,
+ setLoaded,
+ setFailed,
+ }
+}
diff --git a/packages/components/image/src/Image.vue b/packages/components/image/src/Image.vue
deleted file mode 100644
index 281e2401d..000000000
--- a/packages/components/image/src/Image.vue
+++ /dev/null
@@ -1,93 +0,0 @@
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/components/image/src/ImageViewer.tsx b/packages/components/image/src/ImageViewer.tsx
new file mode 100644
index 000000000..5c5df5d36
--- /dev/null
+++ b/packages/components/image/src/ImageViewer.tsx
@@ -0,0 +1,43 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import type { ImageViewerProps } from './types'
+import type { ImageViewerConfig } from '@idux/components/config'
+import type { ComputedRef } from 'vue'
+
+import { Transition, computed, defineComponent } from 'vue'
+
+import { CdkPortal } from '@idux/cdk/portal'
+import { useControlledProp } from '@idux/cdk/utils'
+import { useGlobalConfig } from '@idux/components/config'
+
+import ImageViewerContent from './component/ImageViewerContent'
+import { imageViewerProps } from './types'
+
+export default defineComponent({
+ name: 'IxImageViewer',
+ props: imageViewerProps,
+ setup(props) {
+ const common = useGlobalConfig('common')
+ const config = useGlobalConfig('imageViewer')
+ const [visible] = useControlledProp(props, 'visible', false)
+ const mergedPrefixCls = computed(() => `${common.prefixCls}-image-viewer`)
+ const target = useTarget(props, config, mergedPrefixCls)
+
+ return () => (
+
+
+ {visible.value && }
+
+
+ )
+ },
+})
+
+function useTarget(props: ImageViewerProps, config: ImageViewerConfig, mergedPrefixCls: ComputedRef) {
+ return computed(() => props.target ?? config.target ?? `${mergedPrefixCls.value}-container`)
+}
diff --git a/packages/components/image/src/ImgPreview.vue b/packages/components/image/src/ImgPreview.vue
deleted file mode 100644
index f7ddc3571..000000000
--- a/packages/components/image/src/ImgPreview.vue
+++ /dev/null
@@ -1,83 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/packages/components/image/src/component/ImageViewerContent.tsx b/packages/components/image/src/component/ImageViewerContent.tsx
new file mode 100644
index 000000000..fbce58fc2
--- /dev/null
+++ b/packages/components/image/src/component/ImageViewerContent.tsx
@@ -0,0 +1,286 @@
+/**
+ * @license
+ *
+ * Use of this source code is governed by an MIT-style license that can be
+ * found in the LICENSE file at https://github.com/IDuxFE/idux/blob/main/LICENSE
+ */
+
+import type { ImageViewerContentProps } from '../types'
+import type { ImageViewerConfig } from '@idux/components/config'
+import type { ComputedRef, Ref } from 'vue'
+
+import { computed, defineComponent, normalizeClass, onBeforeUnmount, onMounted, ref, watch, watchEffect } from 'vue'
+
+import { debounce } from 'lodash-es'
+
+import { isFirefox } from '@idux/cdk/platform'
+import { useControlledProp } from '@idux/cdk/utils'
+import { useGlobalConfig } from '@idux/components/config'
+import { IxIcon } from '@idux/components/icon'
+
+import { imageViewerContentProps } from '../types'
+
+const mousewheelEventName = isFirefox ? 'DOMMouseScroll' : 'mousewheel'
+const debounceTime = 10
+
+type ScaleType = 'in' | 'out'
+type RotateType = 'left' | 'right'
+type GoType = 'previous' | 'next'
+
+interface OprType {
+ icon: string
+ key: string
+ opr: () => void
+ visible: boolean
+ disabled?: boolean
+}
+
+export default defineComponent({
+ name: 'IxImageViewerContent',
+ props: imageViewerContentProps,
+ setup(props) {
+ const config = useGlobalConfig('imageViewer')
+ const zoom = useZoomRange(props, config)
+ const maskClosable = useMaskClosable(props, config)
+ const [visible, setVisible] = useControlledProp(props, 'visible', false)
+ const { calcTransform, scaleDisabled, rotateHandle, scaleHandle, resetTransform } = useStyleOpr(zoom)
+ const { activeIndex, switchDisabled, switchVisible, goHandle } = useSwitch(props, config)
+ const oprList = useOprList(
+ {
+ goNext: () => goHandle('next'),
+ goPrevious: () => goHandle('previous'),
+ rotateLeft: () => rotateHandle('left'),
+ rotateRight: () => rotateHandle('right'),
+ zoomOut: () => scaleHandle('out'),
+ zoomIn: () => scaleHandle('in'),
+ close: () => setVisible(false),
+ },
+ scaleDisabled,
+ switchDisabled,
+ switchVisible,
+ )
+ const { onWheelScroll, onKeydown } = getImageEvent(visible, { setVisible, scaleHandle, goHandle })
+ const onClickLayer = () => maskClosable.value && setVisible(false)
+
+ onMounted(() => {
+ window.addEventListener(mousewheelEventName, onWheelScroll, { passive: false, capture: false })
+ window.addEventListener('keydown', onKeydown, false)
+ })
+
+ onBeforeUnmount(() => {
+ window.removeEventListener(mousewheelEventName, onWheelScroll)
+ window.removeEventListener('keydown', onKeydown)
+ })
+
+ watch([visible, activeIndex], ([visible$$]) => {
+ visible$$ && resetTransform()
+ })
+
+ return () => (
+
+ {renderOprNode(props, oprList)}
+ {renderPreviewImg(props, calcTransform, activeIndex, onClickLayer)}
+
+ )
+ },
+})
+
+function renderOprNode(props: ImageViewerContentProps, oprList: ComputedRef) {
+ return (
+
+ {oprList.value
+ .filter(item => item.visible)
+ .map(item => {
+ const iconClasses = computed(() =>
+ normalizeClass([
+ `${props.mergedPrefixCls}-opr-item`,
+ { [`${props.mergedPrefixCls}-opr-item-disabled`]: item.disabled },
+ ]),
+ )
+ return
+ })}
+
+ )
+}
+
+function renderPreviewImg(
+ props: ImageViewerContentProps,
+ calcTransform: ComputedRef>,
+ activeIndex: ComputedRef,
+ onClickLayer: () => void,
+) {
+ const curImgSrc = (props.images ?? [])[activeIndex.value]
+ return (
+
+
+
+ )
+}
+
+function useOprList(
+ { goNext, goPrevious, rotateLeft, rotateRight, zoomOut, zoomIn, close }: Record void>,
+ scaleDisabled: ComputedRef>,
+ switchDisabled: ComputedRef>,
+ switchVisible: ComputedRef,
+): ComputedRef {
+ return computed(() => [
+ {
+ key: 'goPrevious',
+ icon: 'left',
+ opr: goPrevious,
+ disabled: switchDisabled.value.previous,
+ visible: switchVisible.value,
+ },
+ { key: 'goNext', icon: 'right', opr: goNext, disabled: switchDisabled.value.next, visible: switchVisible.value },
+ { key: 'rotateLeft', icon: 'rotate-left', opr: rotateLeft, visible: true },
+ { key: 'rotateRight', icon: 'rotate-right', opr: rotateRight, visible: true },
+ { key: 'zoomOut', icon: 'zoom-out', opr: zoomOut, disabled: scaleDisabled.value.out, visible: true },
+ { key: 'zoomIn', icon: 'zoom-in', opr: zoomIn, disabled: scaleDisabled.value.in, visible: true },
+ { key: 'close', icon: 'close', opr: close, visible: true },
+ ])
+}
+
+function useSwitch(props: ImageViewerContentProps, config: ImageViewerConfig) {
+ const [activeIndex, setIndex] = useControlledProp(props, 'activeIndex', 0)
+ const loop = computed(() => props.loop ?? config.loop)
+ const switchDisabled = computed(() => ({
+ previous: !loop.value && activeIndex.value === 0,
+ next: !loop.value && activeIndex.value === props.images.length - 1,
+ }))
+ const switchVisible = computed(() => props.images.length > 1)
+ const goHandle = debounce((direction: GoType = 'next') => {
+ if (direction === 'next') {
+ if (switchDisabled.value.next) {
+ return
+ }
+ setIndex(activeIndex.value >= props.images.length - 1 ? 0 : activeIndex.value + 1)
+ return
+ }
+ if (switchDisabled.value.previous) {
+ return
+ }
+ setIndex(activeIndex.value <= 0 ? props.images.length - 1 : activeIndex.value - 1)
+ }, debounceTime)
+
+ return {
+ activeIndex,
+ switchDisabled,
+ switchVisible,
+ goHandle,
+ }
+}
+
+function useStyleOpr(zoom: ComputedRef) {
+ const initScale = computed(() => getInitScale(zoom.value))
+ const initRotate = 0
+ const scale = ref(1)
+ const rotate = ref(initRotate)
+ const rotateFactor = {
+ left: -1,
+ right: 1,
+ } as const
+ const scaleFactor = {
+ in: 1,
+ out: -1,
+ }
+
+ watchEffect(() => (scale.value = initScale.value))
+
+ const scaleDisabled = computed(() => ({
+ in: scale.value >= zoom.value[1],
+ out: scale.value <= zoom.value[0],
+ }))
+ const calcTransform = computed(() => ({ transform: `scale(${scale.value}) rotate(${rotate.value}deg)` }))
+
+ const rotateHandle = debounce((direction: RotateType = 'left', rotateStep = 90) => {
+ rotate.value = rotate.value + rotateStep * rotateFactor[direction]
+ }, debounceTime)
+
+ const scaleHandle = debounce((direction: ScaleType, scaleStep = 0.2) => {
+ if (scaleDisabled.value[direction]) {
+ return
+ }
+ scale.value = scale.value + scaleStep * scaleFactor[direction]
+ }, debounceTime)
+
+ const resetTransform = () => {
+ scale.value = initScale.value
+ rotate.value = initRotate
+ }
+
+ return {
+ calcTransform,
+ scaleDisabled,
+ rotateHandle,
+ scaleHandle,
+ resetTransform,
+ }
+}
+
+function useZoomRange(props: ImageViewerContentProps, config: ImageViewerConfig) {
+ return computed(() => props.zoom ?? config.zoom)
+}
+
+function useMaskClosable(props: ImageViewerContentProps, config: ImageViewerConfig) {
+ return computed(() => props.maskClosable ?? config.maskClosable)
+}
+
+function getImageEvent(
+ visible: Ref,
+ {
+ setVisible,
+ scaleHandle,
+ goHandle,
+ }: {
+ setVisible: (visible: boolean) => void
+ scaleHandle: (direction: ScaleType, step?: number) => void
+ goHandle: (direction: GoType) => void
+ },
+) {
+ const scroll = (e: WheelEvent | Event) => {
+ if (!visible.value) {
+ return
+ }
+ const event = e as WheelEvent & { wheelDelta?: number }
+ event.preventDefault()
+ const delta = event.wheelDelta ?? -event.detail
+ if (delta > 0) {
+ scaleHandle('in', 0.2)
+ } else {
+ scaleHandle('out', 0.2)
+ }
+ }
+
+ const keyHandle: Record void> = {
+ ArrowUp: () => scaleHandle('in', 0.2),
+ ArrowDown: () => scaleHandle('out', 0.2),
+ ArrowLeft: () => goHandle('previous'),
+ ArrowRight: () => goHandle('next'),
+ Escape: () => setVisible(false),
+ }
+ const keyDown = (e: KeyboardEvent) => {
+ if (!visible.value) {
+ return
+ }
+ e.preventDefault()
+ if (e.code in keyHandle) {
+ keyHandle[e.code]()
+ }
+ }
+
+ return {
+ onWheelScroll: scroll,
+ onKeydown: keyDown,
+ }
+}
+
+function getInitScale(zoom: number[]) {
+ const defaultScale = 1
+ if (zoom[0] > defaultScale) {
+ return zoom[0]
+ }
+ if (zoom[1] < defaultScale) {
+ return zoom[1]
+ }
+ return defaultScale
+}
diff --git a/packages/components/image/src/types.ts b/packages/components/image/src/types.ts
index 64a7c03bf..fbcec0981 100644
--- a/packages/components/image/src/types.ts
+++ b/packages/components/image/src/types.ts
@@ -8,18 +8,34 @@
import type { IxInnerPropTypes, IxPublicPropTypes } from '@idux/cdk/utils'
import type { DefineComponent, HTMLAttributes } from 'vue'
+import { ɵPortalTargetDef } from '@idux/cdk/portal'
import { IxPropTypes } from '@idux/cdk/utils'
export type ImageStatus = 'loading' | 'loaded' | 'failed'
+const zoomValidator = {
+ validator: (val: number[]) => val.length === 2,
+ msg: 'zoom only accepts the length of the array is 2',
+}
+
+export const imageViewerProps = {
+ visible: IxPropTypes.bool,
+ activeIndex: IxPropTypes.number,
+ images: IxPropTypes.array().isRequired,
+ zoom: IxPropTypes.custom(zoomValidator.validator, zoomValidator.msg),
+ loop: IxPropTypes.bool,
+ target: ɵPortalTargetDef,
+ maskClosable: IxPropTypes.bool,
+ 'onUpdate:visible': IxPropTypes.emit<(visible: boolean) => void>(),
+ 'onUpdate:activeIndex': IxPropTypes.emit<(curIndex: number) => void>(),
+}
+
export const imageProps = {
- src: IxPropTypes.string.def(''),
- width: IxPropTypes.oneOfType([String, Number]),
- height: IxPropTypes.oneOfType([String, Number]),
- preview: IxPropTypes.bool.def(false),
- fallback: IxPropTypes.string,
- alt: IxPropTypes.string.def(''),
- objectFit: IxPropTypes.string.def('fill'),
+ src: IxPropTypes.string.isRequired,
+ preview: IxPropTypes.bool,
+ imageViewer: IxPropTypes.shape({ ...imageViewerProps, images: IxPropTypes.array() }),
+ onLoad: IxPropTypes.emit<(e: Event) => void>(),
+ onError: IxPropTypes.emit<(e: Event) => void>(),
}
export type ImageProps = IxInnerPropTypes
@@ -27,13 +43,16 @@ export type ImagePublicProps = IxPublicPropTypes
export type ImageComponent = DefineComponent & ImagePublicProps>
export type ImageInstance = InstanceType>
-export const imagePreviewProps = {
- previewSrc: IxPropTypes.string.def(''),
-}
-
-export type ImagePreviewProps = IxInnerPropTypes
-export type ImagePreviewPublicProps = IxPublicPropTypes
-export type ImagePreviewComponent = DefineComponent<
- Omit & ImagePreviewPublicProps
+export type ImageViewerProps = IxInnerPropTypes
+export type ImageViewerPublicProps = IxPublicPropTypes
+export type ImageViewerComponent = DefineComponent<
+ Omit & ImageViewerPublicProps
>
-export type ImagePreviewInstance = InstanceType>
+export type ImageViewerInstance = InstanceType>
+
+// private
+export const imageViewerContentProps = {
+ mergedPrefixCls: IxPropTypes.string.isRequired,
+ ...imageViewerProps,
+}
+export type ImageViewerContentProps = IxInnerPropTypes
diff --git a/packages/components/image/style/image.less b/packages/components/image/style/image.less
new file mode 100644
index 000000000..016ded336
--- /dev/null
+++ b/packages/components/image/style/image.less
@@ -0,0 +1,78 @@
+@import '../../style/mixins/reset.less';
+
+.@{image-prefix} {
+ .reset-component();
+
+ position: relative;
+ display: inline-flex;
+ min-width: @image-min-width;
+ min-height: @image-min-height;
+
+ &-layer {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ z-index: @image-layer-z-index;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ .@{image-prefix}-placeholder, .@{image-prefix}-fallback {
+ font-size: 32px;
+ }
+ }
+
+ &-failed {
+ .@{image-prefix}-layer {
+ background-color: @color-grey-l30;
+ }
+ }
+
+ &-preview:not(.@{image-prefix}-failed) {
+ .@{image-prefix}-preview-wrapper {
+ cursor: pointer;
+ display: none;
+
+ .@{image-prefix}-preview-icon {
+ color: @image-preview-icon-color;
+ font-size: @image-preview-icon-font-size;
+ }
+ }
+
+
+ &::after {
+ transition: all .3s;
+ content: '';
+ display: inline-block;
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ z-index: @image-preview-bg-z-index;
+ background-color: @image-preview-bg-color;
+ left: 0;
+ top: 0;
+ opacity: 0;
+ }
+
+ &:hover {
+ .@{image-prefix}-preview-wrapper {
+ display: inline-block;
+ }
+
+ &::after {
+ opacity: 1;
+ }
+ }
+ }
+
+ &-inner {
+ min-height: 100%;
+ min-width: 100%;
+ object-fit: @image-object-fit;
+
+ &-hidden {
+ visibility: hidden;
+ }
+ }
+}
+
diff --git a/packages/components/image/style/index.less b/packages/components/image/style/index.less
deleted file mode 100644
index 52bd23ec4..000000000
--- a/packages/components/image/style/index.less
+++ /dev/null
@@ -1,75 +0,0 @@
-@import '../../style/mixins/reset.less';
-
-.ix-image-preview-base-position(@zIndex,@top) {
- position: fixed;
- left: 0;
- right: 0;
- top: @top;
- bottom: 0;
- z-index: @zIndex;
-}
-
-.@{image-prefix} {
- .reset-component();
-
- &-img.@{image-preview-prefix}-is {
- cursor: pointer;
- }
-}
-
-.@{image-preview-prefix} {
- cursor: default;
- user-select: none;
-
- &-mask {
- .ix-image-preview-base-position(@image-zindex,0);
-
- background-color: @image-mask-bg;
- height: @image-height;
- }
-
- &-tools {
- .ix-image-preview-base-position(@image-zindex + 1,0);
-
- height: @image-tools-h;
- background: @image-tools-bg;
-
- .ix-preview-tools {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- list-style: none;
- height: @image-height;
-
- .ix-tools-item {
- padding: 0 12px;
- cursor: pointer;
- color: #fff;
-
- &:not(:first-child) {
- margin-left: 12px;
- }
- }
-
- .ix-tools-item-disabled {
- color: rgb(255 255 255 / 25%);
- pointer-events: none;
- cursor: not-allowed;
- }
- }
- }
-
- .ix-image-preview-img {
- .ix-image-preview-base-position(@image-zindex,@image-tools-h);
-
- height: @image-height;
- display: flex;
- justify-content: center;
-
- img {
- height: @image-height;
- object-fit: none;
- transition: transform 0.3s cubic-bezier(0.215, 0.61, 0.355, 1) 0s;
- }
- }
-}
diff --git a/packages/components/image/style/themes/default.less b/packages/components/image/style/themes/default.less
index 329463be6..9b5cfb703 100644
--- a/packages/components/image/style/themes/default.less
+++ b/packages/components/image/style/themes/default.less
@@ -1,3 +1,3 @@
-@import '../../../style/themes/default.less';
-@import '../index.less';
+@import '../image.less';
+@import '../viewer.less';
@import './default.variable.less';
diff --git a/packages/components/image/style/themes/default.ts b/packages/components/image/style/themes/default.ts
index 8aaddc579..027ca3f89 100644
--- a/packages/components/image/style/themes/default.ts
+++ b/packages/components/image/style/themes/default.ts
@@ -1,5 +1,4 @@
// style dependencies
import '@idux/components/style/core/default'
-import '@idux/components/icon/style/themes/default'
import './default.less'
diff --git a/packages/components/image/style/themes/default.variable.less b/packages/components/image/style/themes/default.variable.less
index 697660feb..b99b21f8c 100644
--- a/packages/components/image/style/themes/default.variable.less
+++ b/packages/components/image/style/themes/default.variable.less
@@ -1,6 +1,25 @@
-@image-zindex: @zindex-l4-6;
-@image-mask-bg: ~'rgba(0, 0, 0, 0.45)';
-@image-tools-bg: ~'rgba(0, 0, 0, 0.1)';
-@image-height: 100%;
-@image-width: 100%;
-@image-tools-h: 46px;
+@import '../../../style/themes/default.less';
+
+@image-min-width: 96px;
+@image-min-height: 96px;
+@image-layer-z-index: @zindex-l1-2;
+@image-object-fit: contain;
+@image-preview-bg-color: rgba(0, 0, 0, 0.5);
+@image-preview-bg-z-index: @zindex-l1-1;
+@image-preview-icon-color: @color-white;
+@image-preview-icon-font-size: @font-size-xl;
+
+@image-viewer-bg-color: rgba(0, 0, 0, 0.45);
+@image-viewer-z-index: @zindex-l4-6;
+@image-viewer-opr-color: @color-white;
+@image-viewer-opr-z-index: @zindex-l1-1;
+@image-viewer-opr-disabled-color: rgba(255, 255, 255, 0.35);
+@image-viewer-opr-height: 48px;
+@image-viewer-opr-bottom: 48px;
+@image-viewer-opr-font-size: @font-size-xl;
+@image-viewer-opr-bg-color: rgba(0, 0, 0, 0.1);
+@image-viewer-opr-border-radius: calc(@image-viewer-opr-height / 2);
+@image-viewer-opr-item-margin: 0 24px;
+
+@image-viewer-preview-img-max-width: 100%;
+@image-viewer-preview-img-max-height: 100%;
diff --git a/packages/components/image/style/viewer.less b/packages/components/image/style/viewer.less
new file mode 100644
index 000000000..f6a31fbc1
--- /dev/null
+++ b/packages/components/image/style/viewer.less
@@ -0,0 +1,56 @@
+@import '../../style/mixins/reset.less';
+@import '../../style/motion/zoom.less';
+
+.@{image-viewer-prefix} {
+ .reset-component();
+
+ position: fixed;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background: @image-viewer-bg-color;
+ user-select: none;
+ z-index: @image-viewer-z-index;
+
+ .@{image-viewer-prefix}-opr {
+ position: absolute;
+ z-index: @image-viewer-opr-z-index;
+ bottom: @image-viewer-opr-bottom;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+ border-radius: @image-viewer-opr-border-radius;
+ height: @image-viewer-opr-height;
+ color: @image-viewer-opr-color;
+ font-size: @image-viewer-opr-font-size;
+ background-color: @image-viewer-opr-bg-color;
+
+ &-item {
+ margin: @image-viewer-opr-item-margin;
+ cursor: pointer;
+
+ &-disabled {
+ pointer-events: none;
+ color: @image-viewer-opr-disabled-color;
+ }
+ }
+ }
+
+ .@{image-viewer-prefix}-preview {
+ width: 100%;
+ height: 100%;;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &-img {
+ transition: all .3s;
+ will-change: transform;
+ max-height: @image-viewer-preview-img-max-height;
+ max-width: @image-viewer-preview-img-max-width;
+ }
+ }
+}
diff --git a/packages/components/index.ts b/packages/components/index.ts
index fbb70e600..e71d467cf 100644
--- a/packages/components/index.ts
+++ b/packages/components/index.ts
@@ -28,7 +28,7 @@ import { IxForm, IxFormItem, IxFormWrapper } from '@idux/components/form'
import { IxCol, IxRow } from '@idux/components/grid'
import { IxHeader } from '@idux/components/header'
import { IxIcon } from '@idux/components/icon'
-import { IxImage } from '@idux/components/image'
+import { IxImage, IxImageViewer } from '@idux/components/image'
import { IxInput } from '@idux/components/input'
import { IxInputNumber } from '@idux/components/input-number'
import { IxLayout, IxLayoutContent, IxLayoutFooter, IxLayoutHeader, IxLayoutSider } from '@idux/components/layout'
@@ -97,6 +97,7 @@ const components = [
IxHeader,
IxIcon,
IxImage,
+ IxImageViewer,
IxInput,
IxInputNumber,
IxLayout,
diff --git a/packages/components/style/variable/prefix.less b/packages/components/style/variable/prefix.less
index d37fa98e8..e9b5bef29 100644
--- a/packages/components/style/variable/prefix.less
+++ b/packages/components/style/variable/prefix.less
@@ -33,7 +33,7 @@
@collapse-panel-prefix: ~'@{idux-prefix}-collapse-panel';
@empty-prefix: ~'@{idux-prefix}-empty';
@image-prefix: ~'@{idux-prefix}-image';
-@image-preview-prefix: ~'@{image-prefix}-preview';
+@image-viewer-prefix: ~'@{image-prefix}-viewer';
@statistic-prefix: ~'@{idux-prefix}-statistic';
@timeline-prefix: ~'@{idux-prefix}-timeline';
@timeline-item-prefix: ~'@{idux-prefix}-timeline-item';
diff --git a/packages/components/types.d.ts b/packages/components/types.d.ts
index 85136e6cf..b1df17dd6 100644
--- a/packages/components/types.d.ts
+++ b/packages/components/types.d.ts
@@ -25,7 +25,7 @@ import type { FormComponent, FormItemComponent, FormWrapperComponent } from '@id
import type { ColComponent, RowComponent } from '@idux/components/grid'
import type { HeaderComponent } from '@idux/components/header'
import type { IconComponent } from '@idux/components/icon'
-import type { ImageComponent } from '@idux/components/image'
+import type { ImageComponent, ImageViewerComponent } from '@idux/components/image'
import type { InputComponent } from '@idux/components/input'
import type { InputNumberComponent } from '@idux/components/input-number'
import type {
@@ -102,6 +102,7 @@ declare module 'vue' {
IxHeader: HeaderComponent
IxIcon: IconComponent
IxImage: ImageComponent
+ IxImageViewer: ImageViewerComponent
IxInput: InputComponent
IxInputNumber: InputNumberComponent
IxLayout: LayoutComponent
diff --git a/packages/site/public/images/example/css.png b/packages/site/public/images/example/css.png
new file mode 100644
index 0000000000000000000000000000000000000000..3b433a9229c8c12f99f2d19424582949e4884ffe
GIT binary patch
literal 14877
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
literal 0
HcmV?d00001
diff --git a/packages/site/public/images/example/html.png b/packages/site/public/images/example/html.png
new file mode 100644
index 0000000000000000000000000000000000000000..de61756cc93f3d9a3066447ac943418b41843d0b
GIT binary patch
literal 13313
zcmb_?cRbhO_vrKfd_GooMA^ySqB8TTNLFUDN!fd3k9VbrP_l}Um4>~y60(KJ-YYX?
zkNfz3@4c`4&;9TBd6~~R=R9Yh^E}TrZYz?XVmJi=AXmC2uLS@G{)qv^gz(p$>Eq(clpe#8$#;Qu7fxAffrAf-9^M!?&|vjDIGCHZSQUWv;izIpHZy~no4418IQ
zZUGL3P#j4JJ8u;HinZ2_wr(mot*0h#zB~q_L`AFcvI{|?<{m$B8(8((Ya$eW976i2
zI{9Bs=l)@vnRo%kR%N5aYGvT6L8nhF4pRF+_Q9)t+ydi&=sEqJza{VbM$vI?>euqx
z^aNd1i86`WEH6B0^H4f=e%x>W`BL`*o;40N=1k@E(
z8+ISdG;|(wzdh)creZ+=8f+S~Il=3<@-OPBlGjh@zheRm$Gy|ZlE(?@?KDTKDfGku
zR1I@3^mFfc*YYg%&>YC5+=XK@hp~-@!os}@Q2~LNWb7s#g!e&3e1+8c{SlE;|Lt)%
zbd(){wJlE${{zg?QFhePXOeJ&c|i<78IJJXs!wZ4$2m^^aeTEI**Ud(SdgClZb<_T
zfV;A0YUA8Tq<3~zI1W49pJ6sB5r7w@SK5nP9iKZcPyd`LPM6{dsj#h3?>3bSPOenx
zw#t;D`wwNd`~Klp*xObH#<)i&Q_nCOLk}7Hv56gWUG5#!z$YAl6!Igxa8JU-KO<2`
z+;jP$8V^7hA*1-{Sm3AQJKYu?_Yt2*aGBs$p#{0X*1%)_s3Vh%d@w-`oZk~hJ)_yf
z9z-ef{qG9;bKgO}fTMy0Rb+AAmV?>h*(YBgA5
z=Vv~?5#^XGL&qkL0ed;G+}*DyThQz!_J@m?=Ra(=;>hk}!BH`*05E8oBDPHcM357e
z^*OOJJS8>!Ie-4d9%bd>kCb3|e6UG{0D!VUmc_?ra`)q;oWJa8#U56VX2;yr9mM
zOXSXQB7B((3(1nktsIA;*5}C*#US5p_KcMxrH$q&Ib@fdnc2urhHNx
z+aY7k^K_htH5BYpGUNvtd
z!TcG>khdniWm&7LHyN%_+h)o!rO2Hq;(lVftkup~pyAX84yZ!0;2knuT*jW4?v8EQ
z`9%Pf4&xEAh;UsVKkK~Sni4Xd_BOWPH4T{{N=Xyc(S(uFrR1Y8L3
zB;9F;>Sdlr!-ihojc8OsB2^4NNz<}jhE$a7&J`1ZO$4gT|Dy(=>&E{_XIRmI-GX%1
z+dE>NGXmRg|MsJVlE&wl-;P_o3ky7Nvw@TA^gTFM?mk0vP^HMdqd)ft3N3zoF1z+g
z@`&Ie@<{+b^&-s`@u4;oTIcSinftyPS1*o
zx)j^3cXry$iVC+Rp!N1C*0bL7aawlSBzJm$Cf+`0>nt?Zf-|E>{A@tyWi*t>+OsyX
zd<+Ip!1cG40&t7+8xSEWkG~5`gQ`dRKc$GlnX|P^V2!X
zZywVFuP1+1zdQyS*F*ybknx8NH`_vW@gU3W_X`N!xx0%{hUNsB^FLXlh?vyO6S_)L
zFILPReTe+UqR2`O_--ZNX&x}ik);4xf(a(Kqy9T=%iH*t9v$U>{x-{i7{qA&I|Z~d
zMGY?DP-U&jI2Is(zsMGjcac*8j@Nn=*+D>qWDfe)=oeP^1aYXr>5udvo;*7kXedp<
z<hS?$c8^alyb8Q}@Hti%$1C$eJG9T~<6bk_$HVc=`H`t0Hr<
zVyFP0lJC|FofiJ$CItL?z^qa!NuNQ7!K+CY(g3{E%s0oZw~$9aF*NvEO)_H|B!wNS
z@4RS-{Kqh$p*b1HijQ>RQZ}xKvJ-pV&Y3Y-kB@XHkMaZuSyN4+Z65_9h0uqP2`$QQ
zOIUd|UK+eh=%nXId2;-n@5iBSr_SU7gL?aerd6Ld&?^tJLg?awLB>vpQY>Ql-i{sU
zh!>rQHe^TJv=FGokWSrM7YgagW-*$_$u5`-&IP^~z0=g&~+z8Qu&OcB}`sS9b8psEneRaQds8Bp}z#}WH$ao#;UjT*yn-*6DWzjW^WR+fBuz}
z9hD~3PGJy@(nZb+1d~-?NCd(=2cLkh(-|SqOP_L+IThb%wR{a&KTn(|04WrmoT!$y
zUmqO~jpfXAAN{0a(9EY)Oo+CqVtC
znBVR?9a${j8>fBp6>lh1`DIJQ`BS&`gqY`^afb^@3Wx2x!5~hQj+b}d8<6Jqu^?UN
zN_Il+^lPlB%`zwSKSP;v5RH&%%tB9t={jfebvxkiFouUAsTlgNJC8G`rbxA>s``oN
zc?>1~_{ughfGM7F-SeO*L_w{|vLK`1HWD+B+ekv3A@9Z12cLp2_V-Q^-X2F_?wd5A
z?LUX62um_UqN(|>g#pfUPtiI_muy6&+_9xq_uJK}iV(O1Oa3kMei{{(1b4~&UF`Vo
zmaxmb{t%=s-R7o?Qv^XQK;0(FapNO|UJt}vl3kePArp498Nc0*%*TO5#xXGN!5fq6
zI?}LjvQ^36@eIN&T^%ju(rdn!ljQp{`{3mSg7U982sY*Dy4V?bO^c9*Xfwo47)>vw)l3r
zMenVri8HREX`cyr6QZ;mGnuVWHE2p0&1}*$o(`q~gZ66Dl>3b1dBRtiZvyLcg=N7O
zw1WuNYPuHpayTbO=iAkxicrvX(S~Z{mYI@00j}el8%dqil|uTnNp+ak7WY{{0JT
zfQq>qW1J7xxN;Tkiw1+6NtEnZBRhrcKwMh&btyOiUn1I
zoin1H3xT{||9fWNgEcX($iSCL`x-e|D`|rQ5Czd%u^z*LUIWll??MN_7pi}$l?>J(
z%3bF`1t_6MQZih7Q_o?*+I@R(O@`ffF;nNjDOq3uJ(Ous_Xa@TSV&p}
zsuA!=+gljPa!Eu&h6k%oczTK+rZ;G*Rg0Ta%}7Pn?*snz
z1XM?ci-sh{x!)es)
z9e>=NeNr4O`mlBA{!zrlrVJ_I{HQnFH*4H<8m~frVNBfEt8mmqf0-I+--{qXql(J)
zBliUsCE)Rl-Hv;jLR+%7Br@NVih)68fLHAh1N?VooXx&&JeKf;tW=J&=xIGs!pmbv
z;H9wl-R1K@d%}kTm8VIegObuC8N5+RBL;?uK}a)GDR|~!!-7Ln8nk*ht9~Cbpz;8(
zkuwLP$8knq%Osq19mUh=(baJ21l3AThrrQ5oCOQhokKvRsc_|RRl
zFzAxan~=q*HWF#>fBOdO0ar&M@k=)_7!D^jtpG=})K5}W3gD)_Pn%H~G|`@5
zzkavN!;BlBN)H~3G9J5BvN)iIC+l^!hBvEr43?=AnI8h;MuxFP@?MA^YOi@(xXTa{
z7uyeRX;nt(YtNt^e;=&fojU%MG$VEi4sF{&3%qu+n0s{A)
zHd#(MF<5BD5wlgxmrN82kiaB_jhwA#*j~Har
zyK&>MSOPr^ULZ*knX`8Zi+=)FCuT5haQ&$x!)Fpxecho$8kg^%hCyy7b*sDNKQ5ko
z;oYE*4HPNOjQ8mjrSr+guiz;}V*P5~?+xD&!H8V`V;LO^U{B$gQQ^hO
zlO^>WpB@60kPWWb@X1km=HDcEF*f-L$wOf!F@wncrBhH)u1$(A+C?sGRO5}syNpv9yPRdpiPZ|elxu#mF4%s1pNPMJb&z@8JN
zoJ_@!Rh(Z^QQ^R$6lJ#CH5u%J{VYx5(Ysqj@K8S=CK&@x-+x;p`+ZEpAsg~wK19d7
z1Pc`*{%R*D^kp8foVk!%f?fH38X$mq4qBo`=CL#s0ClXuw3-4nn}~l6`IR@wJWPWX
z(`nsIv!NGNeK&Uv?~W`*z%*ujI9`0N@>Nr_wQ)&Rmiy1X!3&bv?tgX3kNT{jUzuFb
zY#%Ih(C|H5twSN3{ZDaayE5fIEBc)bZv{`v-a4y#{z6?DRbB&v=e60{s5SDXrMYsJ
ztmyB>*ekYrLmR9{SagvLg;9iB0C5k>fcmAy?STQWACM~g=gxUk)UmA<@fl!a%b21$|V
zgRP}9LrB-@-IwH+NEdy{eAV=f9`>bxV{KH($#{7^-9s=>y2=1LFN0Ufn$*-lii3ZR
zj?o86;r2@aO0Hz!eX=G3htu_j6I$s=z&sJuf^8x6GV%V4P!BRNKY>mCtwl1_euQsnU0k59
zw=+OXi=^Cl4J{|vI6VTWlVFP8R0_*Py>Tn;6AF3NhNmvK%7}rG_G0q9RZ+mlNO&(<
zO%pP3_D6(+V(8+oNd)w^&YF}MKE5>Oe7XQf-!x`BtuYt_4VTmz+EGv;>^o)0B0H2WJEt}+=<+}
zmaOy@qT{g@M!!8riQ8(4&^a<-2r8klt~I;1*$$m}@G@r-iFKpnIKN2DEEDR;4&0&d
zZdWZ4H}$2h)7eSJj+f$IMw!<{;}pSsN>`5qEry+El@pA)0gT*h%n4@Q;RwT<%;0f>
zLannM7xdk)l9gUT%vrdVay4NfKjTkiDQtRB+l`vZL%_9
z%X(07Yt^GGZwojFtb4UP*g?l`urcEFBVg`|EoB=0O)yExHv+Uk-_m=r7qG|8_p
zvwh@8>Nc3b*yTElXr5auJaDmbQHL7QE#{Ai^Dv=aA|>YSSj8hNO+^U&Pbd+Ez%7H1
zvJ@#LRmF#5pyBUJ{uB3NQtiK8bE%Wx+!dbVCXrAIRs~q*h=Cj;XSnw4Td3~h6#VD+=_$ry>LY5EBoldr$!|-
zfcwJRe6j(UtW!+MfK6j=4C+gcj*;ORG;qI9M$?F{>R1pvs^Em}5Cw6+}xX1gZ>taaN_v#>I+Vz9aKkszP$-+mf?|
z9~2>iyN^pI1vWZ-KXl%xNIx{a`aVNu1^3&$q<|wkch9)fg#>j26_KFJMUtSJL|O#<
zvZQ#wmAj&7MvIrymU8L^dnuAhYPJpemV;X+FpFsVkW{LohspZLNPxOvfz>N_RwQLg
z>y!aDuV6{)q0iOsSe1l|Ervs^;KZ4JlpsXEpT^HY7((l6)*FJRglEap!%Ba#
zC^$VdCqmsA%{m=WclPD0&{OJA&Y_q##4>2r)gKT*b1e#VL$1D>MA
z?iaw$B?Q(DB}mI7)2n+hJ#f)1MUF;*b+t_R3N%NPdU*nG2o88cm$~M5k6ubW+Zq4r1w>n*3drq
zlKVq9qSCm7DH4+jLpxNWa9STZM1KV5l4zCH9ui|VA(fMdC6N^`B1QSuDba(t1nfU|YdSLgDeEE`mq#+XuhTO3(mLd>Q}#7bKU0%kW!;Pyv^_ndAfI0c4bPa%gt
zvEz!~o(PlPxPBAMd4TH@Pb<4}f5;Aee~w;qPlLrX6R2eYQQ{2k-~DGd;Stc2K8fzM
zD~HJFZSGhgV+9yGcaIB>l8Cm8<=#wvfoB-5@f~{`DwU@fz#>Hj<_YI>Vd(~|2j9zx
zR*PcPYIA-3X$+o^{uA&KT5*rn^#
zY+|6Mdp5h<`Ns0SL{0t8cjfB>n6~FE0M={YLcFy-?lH2B0RM;Pdt+{-DI9M0SfhD_
ziVyj((ie8e_?X8ML3`vZ>D;Oq&ny
z%RO!_2O2eM=se~u5nS`5x?I{tK;$8CtjHti+UFUclu*)zb0^@LBw{?@Q;BF3jX&c=
zwR0?7VGn}Rd`039^mbGy+C1|6)DJS0l>#I6)b05T{m}B^-#1QN%mcERIVRFa{(>cP
zfGlv~?ysu3n*%jXIut96{X%Bu-3@WpUagAaWy>b*!~dQS)=TntGJsV#U|GbnWs&AY
z3^c#kOQovu7b}V~GtU#od(^1czgvAB@b~Tm?$`%o`z0+kig&iIvL{IE_nRL+JbhLgUb_}t8lbZf<=M~D{UG{g$@K5K78gph+b(wD
zdm(`8)~i|9I?(mp0iENsm|1kxtXZL3=m}IeFx2$kljief)%0Gc9tb0{i*&Z_ex}AS
z?7q!ty7%GeI_ziB#GSka@NQ}^4i-OCviWwg>R^WnBvWb77S4E?QUi<9`1gG
z<1a4TYtX@f>o*R|9tHz)9!S~Iw*Sv8ttw`
z+VM0;F!>Y)n2k*p(5TRq-yRJ(Oc>f2J-(NrHsSI1wvNjeebL?9m~(R==sF&T@EoBSWrNalIUXs4