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`] = ` -"
\\"demo\\" - -
" -`; - -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 @@ + + + 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 @@ + + + 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 @@ + + + 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 000000000..3b433a922 Binary files /dev/null and b/packages/site/public/images/example/css.png differ diff --git a/packages/site/public/images/example/html.png b/packages/site/public/images/example/html.png new file mode 100644 index 000000000..de61756cc Binary files /dev/null and b/packages/site/public/images/example/html.png differ diff --git a/packages/site/public/images/example/js.png b/packages/site/public/images/example/js.png new file mode 100644 index 000000000..285758d14 Binary files /dev/null and b/packages/site/public/images/example/js.png differ diff --git a/packages/site/public/images/example/nodejs.png b/packages/site/public/images/example/nodejs.png new file mode 100644 index 000000000..a630f530e Binary files /dev/null and b/packages/site/public/images/example/nodejs.png differ