diff --git a/components/image/__tests__/__snapshots__/demo.test.js.snap b/components/image/__tests__/__snapshots__/demo.test.js.snap index 976de10759..9e0e6d31b1 100644 --- a/components/image/__tests__/__snapshots__/demo.test.js.snap +++ b/components/image/__tests__/__snapshots__/demo.test.js.snap @@ -113,3 +113,12 @@ exports[`renders ./components/image/demo/preview-src.vue correctly 1`] = ` `; + +exports[`renders ./components/image/demo/toolbar-render.vue correctly 1`] = ` +
+ +
+
Preview
+
+
+`; diff --git a/components/image/demo/index.vue b/components/image/demo/index.vue index 5fc0911b80..e30082f9fa 100644 --- a/components/image/demo/index.vue +++ b/components/image/demo/index.vue @@ -7,6 +7,7 @@ + @@ -18,6 +19,7 @@ import previewSrc from './preview-src.vue'; import PreviewGroup from './preview-group.vue'; import ControlledPreview from './controlled-preview.vue'; import previewGroupVisibleVue from './preview-group-visible.vue'; +import ToolbarRender from './toolbar-render.vue'; import CN from '../index.zh-CN.md'; import US from '../index.en-US.md'; @@ -33,6 +35,7 @@ export default defineComponent({ PreviewGroup, ControlledPreview, previewGroupVisibleVue, + ToolbarRender, }, }); diff --git a/components/image/demo/preview-group-visible.vue b/components/image/demo/preview-group-visible.vue index 590e6c052f..a4c720178f 100644 --- a/components/image/demo/preview-group-visible.vue +++ b/components/image/demo/preview-group-visible.vue @@ -23,21 +23,16 @@ Preview a collection from one image. src="https://gw.alipayobjects.com/zos/antfincdn/LlvErxo8H9/photo-1503185912284-5271ff81b9a8.webp" @click="visible = true" /> -
- - - - - -
+ + + diff --git a/components/image/demo/toolbar-render.vue b/components/image/demo/toolbar-render.vue new file mode 100644 index 0000000000..3e9cf4f932 --- /dev/null +++ b/components/image/demo/toolbar-render.vue @@ -0,0 +1,89 @@ + +--- +order: 6 +title: + zh-CN: 自定义工具栏 + en-US: Custom toolbar render +--- + +## zh-CN + +可以自定义工具栏并添加下载原图或翻转旋转后图片的按钮。 + +## en-US + +You can customize the toolbar and add a button for downloading the original image or downloading the flipped and rotated image. + + + + + + + + diff --git a/components/image/index.en-US.md b/components/image/index.en-US.md index 5200aba318..3f8c1642fc 100644 --- a/components/image/index.en-US.md +++ b/components/image/index.en-US.md @@ -23,6 +23,7 @@ Previewable image. | placeholder | Load placeholder, use default placeholder when set `true` | boolean \| slot | - | 2.0.0 | | preview | preview config, disabled when `false` | boolean \| [previewType](#previewtype) | true | 2.0.0 | | src | Image path | string | - | 2.0.0 | +| toolbarRender | Custom toolbar render | slot \| [toolbarRenderType](#toolbarrendertype) | - | 4.0.8 | | previewMask | custom mask | false \| function \| slot | - | 3.2.0 | | width | Image width | string \| number | - | 2.0.0 | @@ -45,4 +46,35 @@ Previewable image. } ``` +### TransformType + +```ts +{ + x: number; + y: number; + rotate: number; + scale: number; + flipX: boolean; + flipY: boolean; +} +``` + +### toolbarRenderType + +```ts +{ + actions: { + onFlipY: () => void; + onFlipX: () => void; + onRotateLeft: () => void; + onRotateRight: () => void; + onZoomOut: () => void; + onZoomIn: () => void; + }; + transform: TransformType, + current: number; + total: number; +} +``` + Other attributes [<img>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#Attributes) diff --git a/components/image/index.zh-CN.md b/components/image/index.zh-CN.md index b5a2f148f7..c7cebbb176 100644 --- a/components/image/index.zh-CN.md +++ b/components/image/index.zh-CN.md @@ -24,6 +24,7 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*LVQ3R5JjjJEAAA | placeholder | 加载占位, 为 `true` 时使用默认占位 | boolean \| slot | - | 2.0.0 | | preview | 预览参数,为 `false` 时禁用 | boolean \| [previewType](#previewtype) | true | 2.0.0 | | src | 图片地址 | string | - | 2.0.0 | +| toolbarRender | 自定义工具栏 | slot \| [toolbarRenderType](#toolbarRenderType) | - | 4.0.8 | | previewMask | 自定义 mask | false \| function \| slot | - | 3.2.0 | | width | 图像宽度 | string \| number | - | 2.0.0 | @@ -46,4 +47,35 @@ coverDark: https://mdn.alipayobjects.com/huamei_7uahnr/afts/img/A*LVQ3R5JjjJEAAA } ``` +### TransformType + +```ts +{ + x: number; + y: number; + rotate: number; + scale: number; + flipX: boolean; + flipY: boolean; +} +``` + +### toolbarRenderType + +```ts +{ + actions: { + onFlipY: () => void; + onFlipX: () => void; + onRotateLeft: () => void; + onRotateRight: () => void; + onZoomOut: () => void; + onZoomIn: () => void; + }; + transform: TransformType, + current: number; + total: number; +} +``` + 其他属性见 [<img>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#Attributes) diff --git a/components/image/style/index.ts b/components/image/style/index.ts index 8433f90dd7..a0c5e615ca 100644 --- a/components/image/style/index.ts +++ b/components/image/style/index.ts @@ -4,12 +4,33 @@ import { genModalMaskStyle } from '../../modal/style'; import { initZoomMotion, initFadeMotion } from '../../style/motion'; import type { FullToken, GenerateStyle } from '../../theme/internal'; import { genComponentStyleHook, mergeToken } from '../../theme/internal'; -import { resetComponent, textEllipsis } from '../../style'; +import { textEllipsis } from '../../style'; export interface ComponentToken { + /** + * @desc 预览浮层 z-index + * @descEN z-index of preview popup + */ zIndexPopup: number; + /** + * @desc 预览操作图标大小 + * @descEN Size of preview operation icon + */ previewOperationSize: number; + /** + * @desc 预览操作图标颜色 + * @descEN Color of preview operation icon + */ previewOperationColor: string; + /** + * @desc 预览操作图标悬浮颜色 + * @descEN Color of hovered preview operation icon + */ + previewOperationHoverColor: string; + /** + * @desc 预览操作图标禁用颜色 + * @descEN Disabled color of preview operation icon + */ previewOperationColorDisabled: string; } @@ -27,14 +48,15 @@ export const genBoxStyle = (position?: PositionType): CSSObject => ({ }); export const genImageMaskStyle = (token: ImageToken): CSSObject => { - const { iconCls, motionDurationSlow, paddingXXS, marginXXS, prefixCls } = token; + const { iconCls, motionDurationSlow, paddingXXS, marginXXS, prefixCls, colorTextLightSolid } = + token; return { position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', - color: '#fff', + color: colorTextLightSolid, background: new TinyColor('#000').setAlpha(0.5).toRgbString(), cursor: 'pointer', opacity: 0, @@ -54,22 +76,71 @@ export const genImageMaskStyle = (token: ImageToken): CSSObject => { }; export const genPreviewOperationsStyle = (token: ImageToken): CSSObject => { - const { previewCls, modalMaskBg, paddingSM, previewOperationColorDisabled, motionDurationSlow } = - token; + const { + previewCls, + modalMaskBg, + paddingSM, + marginXL, + margin, + paddingLG, + previewOperationColorDisabled, + previewOperationHoverColor, + motionDurationSlow, + iconCls, + colorTextLightSolid, + } = token; const operationBg = new TinyColor(modalMaskBg).setAlpha(0.1); const operationBgHover = operationBg.clone().setAlpha(0.2); return { - [`${previewCls}-operations`]: { - ...resetComponent(token), + [`${previewCls}-footer`]: { + position: 'fixed', + bottom: marginXL, + left: { + _skip_check_: true, + value: 0, + }, + width: '100%', display: 'flex', - flexDirection: 'row-reverse', + flexDirection: 'column', alignItems: 'center', color: token.previewOperationColor, - listStyle: 'none', - background: operationBg.toRgbString(), - pointerEvents: 'auto', + }, + [`${previewCls}-progress`]: { + marginBottom: margin, + }, + [`${previewCls}-close`]: { + position: 'fixed', + top: marginXL, + right: { + _skip_check_: true, + value: marginXL, + }, + display: 'flex', + color: colorTextLightSolid, + backgroundColor: operationBg.toRgbString(), + borderRadius: '50%', + padding: paddingSM, + outline: 0, + border: 0, + cursor: 'pointer', + transition: `all ${motionDurationSlow}`, + + '&:hover': { + backgroundColor: operationBgHover.toRgbString(), + }, + + [`& > ${iconCls}`]: { + fontSize: token.previewOperationSize, + }, + }, + [`${previewCls}-operations`]: { + display: 'flex', + alignItems: 'center', + padding: `0 ${paddingLG}px`, + backgroundColor: operationBg.toRgbString(), + borderRadius: 100, '&-operation': { marginInlineStart: paddingSM, @@ -78,28 +149,22 @@ export const genPreviewOperationsStyle = (token: ImageToken): CSSObject => { transition: `all ${motionDurationSlow}`, userSelect: 'none', - '&:hover': { - background: operationBgHover.toRgbString(), + [`&:not(${previewCls}-operations-operation-disabled):hover > ${iconCls}`]: { + color: previewOperationHoverColor, }, '&-disabled': { color: previewOperationColorDisabled, - pointerEvents: 'none', + cursor: 'not-allowed', }, - '&:last-of-type': { + '&:first-of-type': { marginInlineStart: 0, }, - }, - '&-progress': { - position: 'absolute', - left: { _skip_check_: true, value: '50%' }, - transform: 'translateX(-50%)', - }, - - '&-icon': { - fontSize: token.previewOperationSize, + [`& > ${iconCls}`]: { + fontSize: token.previewOperationSize, + }, }, }, }; @@ -135,7 +200,6 @@ export const genPreviewSwitchStyle = (token: ImageToken): CSSObject => { transform: `translateY(-50%)`, cursor: 'pointer', transition: `all ${motionDurationSlow}`, - pointerEvents: 'auto', userSelect: 'none', '&:hover': { @@ -186,13 +250,12 @@ export const genImagePreviewStyle: GenerateStyle = (token: ImageToke [`${previewCls}-img`]: { maxWidth: '100%', - maxHeight: '100%', + maxHeight: '70%', verticalAlign: 'middle', transform: 'scale3d(1, 1, 1)', cursor: 'grab', transition: `transform ${motionDurationSlow} ${motionEaseOut} 0s`, userSelect: 'none', - pointerEvents: 'auto', '&-wrapper': { ...genBoxStyle(), @@ -205,6 +268,10 @@ export const genImagePreviewStyle: GenerateStyle = (token: ImageToke justifyContent: 'center', alignItems: 'center', + '& > *': { + pointerEvents: 'auto', + }, + '&::before': { display: 'inline-block', width: 1, @@ -239,10 +306,7 @@ export const genImagePreviewStyle: GenerateStyle = (token: ImageToke { [`${componentCls}-preview-operations-wrapper`]: { position: 'fixed', - insetBlockStart: 0, - insetInlineEnd: 0, zIndex: token.zIndexPopup + 1, - width: '100%', }, '&': [genPreviewOperationsStyle(token), genPreviewSwitchStyle(token)], }, @@ -312,7 +376,10 @@ export default genComponentStyleHook( }, token => ({ zIndexPopup: token.zIndexPopupBase + 80, - previewOperationColor: new TinyColor(token.colorTextLightSolid).toRgbString(), + previewOperationColor: new TinyColor(token.colorTextLightSolid).setAlpha(0.65).toRgbString(), + previewOperationHoverColor: new TinyColor(token.colorTextLightSolid) + .setAlpha(0.85) + .toRgbString(), previewOperationColorDisabled: new TinyColor(token.colorTextLightSolid) .setAlpha(0.25) .toRgbString(), diff --git a/components/vc-image/src/Image.tsx b/components/vc-image/src/Image.tsx index 5c68b8769e..c61d01ff56 100644 --- a/components/vc-image/src/Image.tsx +++ b/components/vc-image/src/Image.tsx @@ -292,6 +292,7 @@ const ImageInternal = defineComponent({ getContainer={getPreviewContainer.value} icons={icons} rootClassName={rootClassName} + v-slots={{ closeIcon: slots.closeIcon, toolbarRender: slots.toolbarRender }} /> )} diff --git a/components/vc-image/src/Preview.tsx b/components/vc-image/src/Preview.tsx index 80d03fc70e..cea059b282 100644 --- a/components/vc-image/src/Preview.tsx +++ b/components/vc-image/src/Preview.tsx @@ -20,6 +20,7 @@ import { warning } from '../../vc-util/warning'; import useFrameSetState from './hooks/useFrameSetState'; import getFixScaleEleTransPosition from './getFixScaleEleTransPosition'; import type { MouseEventHandler, WheelEventHandler } from '../../_util/EventInterface'; +import type { CustomSlotsType } from '../../_util/type'; import { context } from './PreviewGroup'; @@ -54,6 +55,8 @@ export const previewProps = { type: Object as PropType, default: () => ({} as PreviewProps['icons']), }, + minScale: Number, + maxScale: Number, }; const Preview = defineComponent({ compatConfig: { MODE: 3 }, @@ -61,11 +64,18 @@ const Preview = defineComponent({ inheritAttrs: false, props: previewProps, emits: ['close', 'afterClose'], - setup(props, { emit, attrs }) { + slots: Object as CustomSlotsType<{ + closeIcon: any; + countRender: any; + toolbarRender: any; + }>, + setup(props, { emit, attrs, slots }) { const { rotateLeft, rotateRight, zoomIn, zoomOut, close, left, right, flipX, flipY } = reactive( props.icons, ); + const { minScale = 1, maxScale = 50 } = reactive(props); + const scale = shallowRef(1); const rotate = shallowRef(0); const flip = reactive({ x: 1, y: 1 }); @@ -99,6 +109,9 @@ const Preview = defineComponent({ const showLeftOrRightSwitches = computed( () => isPreviewGroup.value && previewGroupCount.value > 1, ); + const showOperationsProgress = computed( + () => isPreviewGroup.value && previewGroupCount.value >= 1, + ); const lastWheelZoomDirection = shallowRef({ wheelDirection: 0 }); const onAfterClose = () => { @@ -110,7 +123,7 @@ const Preview = defineComponent({ emit('afterClose'); }; - const onZoomIn = (isWheel?: boolean) => { + const onZoomIn = isWheel => { if (!isWheel) { scale.value++; } else { @@ -119,7 +132,7 @@ const Preview = defineComponent({ setPosition(initialPosition); }; - const onZoomOut = (isWheel?: boolean) => { + const onZoomOut = isWheel => { if (scale.value > 1) { if (!isWheel) { scale.value--; @@ -171,20 +184,19 @@ const Preview = defineComponent({ const iconClassName = `${props.prefixCls}-operations-icon`; const tools = [ { - icon: close, - onClick: onClose, - type: 'close', + icon: flipY, + onClick: onFlipY, + type: 'flipY', }, { - icon: zoomIn, - onClick: () => onZoomIn(), - type: 'zoomIn', + icon: flipX, + onClick: onFlipX, + type: 'flipX', }, { - icon: zoomOut, - onClick: () => onZoomOut(), - type: 'zoomOut', - disabled: computed(() => scale.value === 1), + icon: rotateLeft, + onClick: onRotateLeft, + type: 'rotateLeft', }, { icon: rotateRight, @@ -192,19 +204,16 @@ const Preview = defineComponent({ type: 'rotateRight', }, { - icon: rotateLeft, - onClick: onRotateLeft, - type: 'rotateLeft', - }, - { - icon: flipX, - onClick: onFlipX, - type: 'flipX', + icon: zoomOut, + onClick: onZoomOut, + type: 'zoomOut', + disabled: computed(() => scale.value === minScale), }, { - icon: flipY, - onClick: onFlipY, - type: 'flipY', + icon: zoomIn, + onClick: onZoomIn, + type: 'zoomIn', + disabled: computed(() => scale.value === maxScale), }, ]; @@ -346,79 +355,119 @@ const Preview = defineComponent({ return () => { const { visible, prefixCls, rootClassName } = props; - return ( - ( +
-
-
    - {tools.map(({ icon: IconType, onClick, type, disabled }) => ( -
  • - {cloneVNode(IconType, { class: iconClassName })} -
  • - ))} -
-
-
+ )); + + const toolbarNode =
{toolsNode}
; + + return ( + <> + - {props.alt} -
- {showLeftOrRightSwitches.value && ( -
- {left} + {props.alt}
- )} - {showLeftOrRightSwitches.value && ( -
= previewGroupCount.value - 1, - })} - onClick={onSwitchRight} - > - {right} +
+ {visible && ( +
+ + + {showLeftOrRightSwitches.value && ( + <> +
+ {left} +
+
+ {right} +
+ + )} + +
+ {showOperationsProgress.value && ( +
+ {`${currentPreviewIndex.value + 1} / ${previewGroupCount.value}`} +
+ )} + {slots.toolbarRender + ? slots.toolbarRender?.({ + actions: { + onFlipY, + onFlipX, + onRotateLeft, + onRotateRight, + onZoomOut, + onZoomIn, + }, + transform: { + x: position.x, + y: position.y, + scale: scale.value, + rotate, + flip, + }, + ...(groupContext + ? { current: currentPreviewIndex.value, total: previewGroupCount.value } + : {}), + }) + : toolbarNode} +
)} - + ); }; }, diff --git a/components/vc-image/src/PreviewGroup.tsx b/components/vc-image/src/PreviewGroup.tsx index 55d81d5ae1..bab1cde8b2 100644 --- a/components/vc-image/src/PreviewGroup.tsx +++ b/components/vc-image/src/PreviewGroup.tsx @@ -198,6 +198,7 @@ const Group = defineComponent({ src={canPreviewUrls.value.get(current.value)} icons={props.icons} getContainer={getPreviewContainer.value} + v-slots={{ closeIcon: slots.closeIcon, toolbarRender: slots.toolbarRender }} /> );