diff --git a/packages/components/config/src/defaultConfig.ts b/packages/components/config/src/defaultConfig.ts
index c73072953..4826357c1 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 = {
@@ -411,6 +415,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 7b847a43f..b74b37f06 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 {
@@ -434,6 +439,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..f1e943780 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..34fb03c09
--- /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..4213a13ab
--- /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..7bc8f2bcf
--- /dev/null
+++ b/packages/components/image/demo/ImageViewer.vue
@@ -0,0 +1,19 @@
+
+ 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..954f80bce
--- /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..47e8c93fa 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 cc92bbf5b..49831dc71 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';