diff --git a/src/image/demos/enUS/index.demo-entry.md b/src/image/demos/enUS/index.demo-entry.md index 791572558..3804e3542 100644 --- a/src/image/demos/enUS/index.demo-entry.md +++ b/src/image/demos/enUS/index.demo-entry.md @@ -13,11 +13,18 @@ group ### Image Props -| Name | Type | Default | Description | -| ------ | ------------------ | ----------- | ----------- | -| src | `string` | `undefined` | | -| width | `string \| number` | `undefined` | | -| height | `string \| number` | `undefined` | | +| Name | Type | Default | Description | +| ------------ | ------------------ | ----------- | ----------- | +| src | `string` | `undefined` | | +| width | `string \| number` | `undefined` | | +| height | `string \| number` | `undefined` | | +| show-toolbar | `boolean` | `true` | | + +### ImageGroup Props + +| 名称 | 类型 | 默认值 | 说明 | +| ------------ | --------- | ------ | ---- | +| show-toolbar | `boolean` | `true` | | ## Slots diff --git a/src/image/demos/zhCN/index.demo-entry.md b/src/image/demos/zhCN/index.demo-entry.md index 44f5d86c6..c1c5c68ee 100644 --- a/src/image/demos/zhCN/index.demo-entry.md +++ b/src/image/demos/zhCN/index.demo-entry.md @@ -13,11 +13,18 @@ group ### Image Props -| 名称 | 类型 | 默认值 | 说明 | -| ------ | ------------------ | ----------- | ---- | -| src | `string` | `undefined` | | -| width | `string \| number` | `undefined` | | -| height | `string \| number` | `undefined` | | +| 名称 | 类型 | 默认值 | 说明 | +| ------------ | ------------------ | ----------- | ---- | +| src | `string` | `undefined` | | +| width | `string \| number` | `undefined` | | +| height | `string \| number` | `undefined` | | +| show-toolbar | `boolean` | `true` | | + +### ImageGroup Props + +| 名称 | 类型 | 默认值 | 说明 | +| ------------ | --------- | ------ | ---- | +| show-toolbar | `boolean` | `true` | | ## Slots diff --git a/src/image/src/Image.tsx b/src/image/src/Image.tsx index d7bb4cc2c..a17b1b7bc 100644 --- a/src/image/src/Image.tsx +++ b/src/image/src/Image.tsx @@ -8,7 +8,8 @@ export default defineComponent({ props: { width: [String, Number], height: [String, Number], - src: String + src: String, + showToolbar: { type: Boolean, default: true } }, setup (props) { const imageRef = ref(null) @@ -46,7 +47,7 @@ export default defineComponent({ /> ) : ( - + {{ default: () => { return ( diff --git a/src/image/src/ImageGroup.tsx b/src/image/src/ImageGroup.tsx index a8839999b..4d7c1df0e 100644 --- a/src/image/src/ImageGroup.tsx +++ b/src/image/src/ImageGroup.tsx @@ -17,6 +17,9 @@ ImagePreviewInst & { groupId: string } export default defineComponent({ name: 'ImageGroup', + props: { + showToolbar: { type: Boolean, default: true } + }, setup () { let currentSrc: string | undefined const groupId = createId() @@ -59,7 +62,12 @@ export default defineComponent({ }, render () { return ( - + {{ default: () => renderSlot(this.$slots, 'default') }} diff --git a/src/image/src/ImagePreview.tsx b/src/image/src/ImagePreview.tsx index af9c5158f..5133e1356 100644 --- a/src/image/src/ImagePreview.tsx +++ b/src/image/src/ImagePreview.tsx @@ -7,7 +7,10 @@ import { Transition, vShow, renderSlot, - watch + watch, + computed, + CSSProperties, + PropType } from 'vue' import { zindexable } from 'vdirs' import { useIsMounted } from 'vooks' @@ -15,7 +18,16 @@ import { LazyTeleport } from 'vueuc' import { on, off } from 'evtd' import { beforeNextFrameOnce } from 'seemly' import { useTheme } from '../../_mixins' +import { NBaseIcon } from '../../_internal' import { imageLight } from '../styles' +import { + zoomInIcon, + zoomOutIcon, + rotateCounterclockwise, + rotateClockwise, + prevIcon, + nextIcon +} from './icons' import style from './styles/index.cssr' export interface ImagePreviewInst { @@ -27,28 +39,27 @@ export interface ImagePreviewInst { export default defineComponent({ name: 'Image', props: { - onNext: Function, - onPrev: Function + showToolbar: Boolean, + onNext: Function as PropType<() => void>, + onPrev: Function as PropType<() => void> }, setup (props) { - useTheme('Image', 'Image', style, imageLight, {}) + const themeRef = useTheme('Image', 'Image', style, imageLight, {}) let thumbnailEl: HTMLImageElement | null = null const previewRef = ref(null) + const previewWrapperRef = ref(null) const previewSrcRef = ref(undefined) const showRef = ref(false) const displayedRef = ref(false) + function syncTransformOrigin (): void { - const { value: preview } = previewRef - if (!thumbnailEl || !preview) return + const { value: previewWrapper } = previewWrapperRef + if (!thumbnailEl || !previewWrapper) return + const { style } = previewWrapper const tbox = thumbnailEl.getBoundingClientRect() const tx = tbox.left + tbox.width / 2 const ty = tbox.top + tbox.height / 2 - preview.style.transform = 'none' - const pbox = preview.getBoundingClientRect() - const px = pbox.left - const py = pbox.top - preview.style.transform = '' - preview.style.transformOrigin = `${tx - px}px ${ty - py}px` + style.transformOrigin = `${tx}px ${ty}px` } function handleKeyup (e: KeyboardEvent): void { @@ -80,33 +91,102 @@ export default defineComponent({ offsetY = clientY - startY beforeNextFrameOnce(derivePreviewStyle) } + // avoid image move outside viewport + function getDerivedOffset (): { + offsetX: number + offsetY: number + } { + const { value: preview } = previewRef + if (!preview) return { offsetX: 0, offsetY: 0 } + const pbox = preview.getBoundingClientRect() + let nextOffsetX = 0 + let nextOffsetY = 0 + if (pbox.width <= window.innerWidth) { + nextOffsetX = 0 + } else if (pbox.left > 0) { + nextOffsetX = (pbox.width - window.innerWidth) / 2 + } else if (pbox.right < window.innerWidth) { + nextOffsetX = -(pbox.width - window.innerWidth) / 2 + } + if (pbox.height <= window.innerHeight) { + nextOffsetY = 0 + } else if (pbox.top > 0) { + nextOffsetY = (pbox.height - window.innerHeight) / 2 + } else if (pbox.bottom < window.innerHeight) { + nextOffsetY = -(pbox.height - window.innerHeight) / 2 + } + return { + offsetX: nextOffsetX, + offsetY: nextOffsetY + } + } function handleMouseUp (): void { off('mousemove', document, handleMouseMove) off('mouseup', document, handleMouseUp) dragging = false - beforeNextFrameOnce(derivePreviewStyle) + const offset = getDerivedOffset() + offsetX = offset.offsetX + offsetY = offset.offsetY + derivePreviewStyle() } function handlePreviewMousedown (e: MouseEvent): void { const { clientX, clientY } = e dragging = true - offsetX = 0 - offsetY = 0 - startX = clientX - startY = clientY - beforeNextFrameOnce(derivePreviewStyle) + startX = clientX - offsetX + startY = clientY - offsetY + derivePreviewStyle() on('mousemove', document, handleMouseMove) on('mouseup', document, handleMouseUp) } - function derivePreviewStyle (): void { + + let scale = 1 + let rotate = 0 + function rotateCounterclockwise (): void { + rotate -= 90 + derivePreviewStyle() + } + function rotateClockwise (): void { + rotate += 90 + derivePreviewStyle() + } + function zoomIn (): void { + if (scale < 3) { + scale += 0.5 + derivePreviewStyle() + } + } + function zoomOut (): void { + if (scale > 0.5) { + scale -= 0.5 + derivePreviewStyle(false) + const offset = getDerivedOffset() + scale += 0.5 + derivePreviewStyle(false) + scale -= 0.5 + offsetX = offset.offsetX + offsetY = offset.offsetY + derivePreviewStyle() + } + } + + function derivePreviewStyle (transition: boolean = true): void { const { value: preview } = previewRef if (!preview) return const { style } = preview + const transformStyle = `transform-origin: center; transform: translateX(${offsetX}px) translateY(${offsetY}px) rotate(${rotate}deg) scale(${scale});` if (dragging) { - style.cssText = `cursor: grabbing; transition: none; transform: translateX(${offsetX}px) translateY(${offsetY}px);` + style.cssText = 'cursor: grabbing; transition: none;' + transformStyle } else { - style.cssText = 'cursor: grab;' + style.cssText = + 'cursor: grab;' + + transformStyle + + (transition ? '' : 'transition: none;') + } + if (!transition) { + void preview.offsetHeight } } + function toggleShow (): void { showRef.value = !showRef.value displayedRef.value = true @@ -123,6 +203,7 @@ export default defineComponent({ return { previewRef, + previewWrapperRef, previewSrc: previewSrcRef, show: showRef, appear: useIsMounted(), @@ -130,9 +211,25 @@ export default defineComponent({ handlePreviewMousedown, syncTransformOrigin, handleAfterLeave: () => { + rotate = 0 + scale = 1 displayedRef.value = false }, - ...exposedMethods + zoomIn, + zoomOut, + rotateCounterclockwise, + rotateClockwise, + ...exposedMethods, + cssVars: computed(() => { + const { + common: { cubicBezierEaseInOut }, + self: { iconColor } + } = themeRef.value + return { + '--bezier': cubicBezierEaseInOut, + '--icon-color': iconColor + } + }) } }, render () { @@ -144,7 +241,10 @@ export default defineComponent({ default: () => this.show || this.displayed ? withDirectives( -
+
+ {this.showToolbar ? ( + + {{ + default: () => + this.show ? ( +
+ {this.onPrev ? ( + <> + + {{ default: () => prevIcon }} + + + {{ default: () => nextIcon }} + + + ) : null} + + {{ default: () => rotateCounterclockwise }} + + + {{ default: () => rotateClockwise }} + + + {{ default: () => zoomOutIcon }} + + + {{ default: () => zoomInIcon }} + +
+ ) : null + }} +
+ ) : null} withDirectives( - , +
+ +
, [[vShow, this.show]] ) }} diff --git a/src/image/src/icons.tsx b/src/image/src/icons.tsx new file mode 100644 index 000000000..dceee68a5 --- /dev/null +++ b/src/image/src/icons.tsx @@ -0,0 +1,71 @@ +import { h } from 'vue' + +export const rotateClockwise = ( + + + + +) + +export const rotateCounterclockwise = ( + + + + +) + +export const zoomInIcon = ( + + + + +) + +export const zoomOutIcon = ( + + + + +) + +export const prevIcon = ( + + + +) + +export const nextIcon = ( + + + +) diff --git a/src/image/src/styles/index.cssr.ts b/src/image/src/styles/index.cssr.ts index 21e756853..3e18e84fc 100644 --- a/src/image/src/styles/index.cssr.ts +++ b/src/image/src/styles/index.cssr.ts @@ -1,7 +1,10 @@ import { c, cB } from '../../../_utils/cssr' import fadeInTransition from '../../../_styles/transitions/fade-in' -import fadeInScaleUpTransiton from '../../../_styles/transitions/fade-in-scale-up' +import fadeInzoomInTransiton from '../../../_styles/transitions/fade-in-scale-up' +// vars: +// --icon-color +// --bezier export default c([ c('body >', [ cB('image-container', 'position: fixed;') @@ -25,13 +28,47 @@ export default c([ `, [ fadeInTransition() ]), + cB('image-preview-toolbar', ` + z-index: 1; + position: absolute; + left: 50%; + transform: translateX(-50%); + border-radius: 24px; + height: 48px; + bottom: 40px; + padding: 0 12px; + background: rgba(0, 0, 0, .35); + color: var(--icon-color); + transition: color .3s var(--bezier); + display: flex; + align-items: center; + `, [ + cB('base-icon', ` + padding: 0 8px; + font-size: 28px; + cursor: pointer; + `), + fadeInTransition() + ]), + cB('image-preview-wrapper', ` + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + display: flex; + pointer-events: none; + `, [ + fadeInzoomInTransiton() + ]), cB('image-preview', ` + user-select: none; + pointer-events: all; margin: auto; max-height: 100vh; - transition: transform .3s cubic-bezier(.4, 0, .2, 1); - `, [ - fadeInScaleUpTransiton() - ]), + max-width: 100vw; + transition: transform .3s var(--bezier); + `), cB('image', ` display: inline-flex; cursor: pointer; diff --git a/src/image/styles/dark.ts b/src/image/styles/dark.ts index 8575ff034..2df8b6868 100644 --- a/src/image/styles/dark.ts +++ b/src/image/styles/dark.ts @@ -4,7 +4,10 @@ import type { ImageTheme } from './light' export const imageDark: ImageTheme = { name: 'Image', common: commonDark, - self: () => { - return {} + self: (vars) => { + const { textColor2 } = vars + return { + iconColor: textColor2 + } } } diff --git a/src/image/styles/light.ts b/src/image/styles/light.ts index e58ea7074..da07edc00 100644 --- a/src/image/styles/light.ts +++ b/src/image/styles/light.ts @@ -2,9 +2,10 @@ import { createTheme } from '../../_mixins' import { commonLight } from '../../_styles/common' function self () { - return {} + return { + iconColor: 'rgba(255, 255, 255, .9)' + } } - export const imageLight = createTheme({ name: 'Image', common: commonLight,