feat(image): toolbar

This commit is contained in:
07akioni 2021-04-07 16:11:28 +08:00
parent ab24d61f45
commit 67fea9eed3
9 changed files with 330 additions and 52 deletions

View File

@ -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

View File

@ -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

View File

@ -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<HTMLImageElement | null>(null)
@ -46,7 +47,7 @@ export default defineComponent({
/>
</div>
) : (
<NImagePreview ref="previewInstRef">
<NImagePreview ref="previewInstRef" showToolbar={this.showToolbar}>
{{
default: () => {
return (

View File

@ -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 (
<NImagePreview ref="previewInstRef" onPrev={this.prev} onNext={this.next}>
<NImagePreview
ref="previewInstRef"
onPrev={this.prev}
onNext={this.next}
showToolbar={this.showToolbar}
>
{{
default: () => renderSlot(this.$slots, 'default')
}}

View File

@ -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<HTMLImageElement | null>(null)
const previewWrapperRef = ref<HTMLDivElement | null>(null)
const previewSrcRef = ref<string | undefined>(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(
<div class="n-image-preview-container">
<div
class="n-image-preview-container"
style={this.cssVars as CSSProperties}
>
<Transition
name="n-fade-in-transition"
appear={this.appear}
@ -159,6 +259,44 @@ export default defineComponent({
) : null
}}
</Transition>
{this.showToolbar ? (
<Transition
name="n-fade-in-transition"
appear={this.appear}
>
{{
default: () =>
this.show ? (
<div class="n-image-preview-toolbar">
{this.onPrev ? (
<>
<NBaseIcon onClick={this.onPrev}>
{{ default: () => prevIcon }}
</NBaseIcon>
<NBaseIcon onClick={this.onNext}>
{{ default: () => nextIcon }}
</NBaseIcon>
</>
) : null}
<NBaseIcon
onClick={this.rotateCounterclockwise}
>
{{ default: () => rotateCounterclockwise }}
</NBaseIcon>
<NBaseIcon onClick={this.rotateClockwise}>
{{ default: () => rotateClockwise }}
</NBaseIcon>
<NBaseIcon onClick={this.zoomOut}>
{{ default: () => zoomOutIcon }}
</NBaseIcon>
<NBaseIcon onClick={this.zoomIn}>
{{ default: () => zoomInIcon }}
</NBaseIcon>
</div>
) : null
}}
</Transition>
) : null}
<Transition
name="n-fade-in-scale-up-transition"
onAfterLeave={this.handleAfterLeave}
@ -169,13 +307,18 @@ export default defineComponent({
{{
default: () =>
withDirectives(
<img
draggable={false}
onMousedown={this.handlePreviewMousedown}
class="n-image-preview"
src={this.previewSrc}
ref="previewRef"
/>,
<div
class="n-image-preview-wrapper"
ref="previewWrapperRef"
>
<img
draggable={false}
onMousedown={this.handlePreviewMousedown}
class="n-image-preview"
src={this.previewSrc}
ref="previewRef"
/>
</div>,
[[vShow, this.show]]
)
}}

71
src/image/src/icons.tsx Normal file
View File

@ -0,0 +1,71 @@
import { h } from 'vue'
export const rotateClockwise = (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 10C3 6.13401 6.13401 3 10 3C13.866 3 17 6.13401 17 10C17 12.7916 15.3658 15.2026 13 16.3265V14.5C13 14.2239 12.7761 14 12.5 14C12.2239 14 12 14.2239 12 14.5V17.5C12 17.7761 12.2239 18 12.5 18H15.5C15.7761 18 16 17.7761 16 17.5C16 17.2239 15.7761 17 15.5 17H13.8758C16.3346 15.6357 18 13.0128 18 10C18 5.58172 14.4183 2 10 2C5.58172 2 2 5.58172 2 10C2 10.2761 2.22386 10.5 2.5 10.5C2.77614 10.5 3 10.2761 3 10Z"
fill="currentColor"
></path>
<path
d="M10 12C11.1046 12 12 11.1046 12 10C12 8.89543 11.1046 8 10 8C8.89543 8 8 8.89543 8 10C8 11.1046 8.89543 12 10 12ZM10 11C9.44772 11 9 10.5523 9 10C9 9.44772 9.44772 9 10 9C10.5523 9 11 9.44772 11 10C11 10.5523 10.5523 11 10 11Z"
fill="currentColor"
></path>
</svg>
)
export const rotateCounterclockwise = (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M17 10C17 6.13401 13.866 3 10 3C6.13401 3 3 6.13401 3 10C3 12.7916 4.63419 15.2026 7 16.3265V14.5C7 14.2239 7.22386 14 7.5 14C7.77614 14 8 14.2239 8 14.5V17.5C8 17.7761 7.77614 18 7.5 18H4.5C4.22386 18 4 17.7761 4 17.5C4 17.2239 4.22386 17 4.5 17H6.12422C3.66539 15.6357 2 13.0128 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10C18 10.2761 17.7761 10.5 17.5 10.5C17.2239 10.5 17 10.2761 17 10Z"
fill="currentColor"
></path>
<path
d="M10 12C8.89543 12 8 11.1046 8 10C8 8.89543 8.89543 8 10 8C11.1046 8 12 8.89543 12 10C12 11.1046 11.1046 12 10 12ZM10 11C10.5523 11 11 10.5523 11 10C11 9.44772 10.5523 9 10 9C9.44772 9 9 9.44772 9 10C9 10.5523 9.44772 11 10 11Z"
fill="currentColor"
></path>
</svg>
)
export const zoomInIcon = (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11.5 8.5C11.5 8.22386 11.2761 8 11 8H9V6C9 5.72386 8.77614 5.5 8.5 5.5C8.22386 5.5 8 5.72386 8 6V8H6C5.72386 8 5.5 8.22386 5.5 8.5C5.5 8.77614 5.72386 9 6 9H8V11C8 11.2761 8.22386 11.5 8.5 11.5C8.77614 11.5 9 11.2761 9 11V9H11C11.2761 9 11.5 8.77614 11.5 8.5Z"
fill="currentColor"
></path>
<path
d="M8.5 3C11.5376 3 14 5.46243 14 8.5C14 9.83879 13.5217 11.0659 12.7266 12.0196L16.8536 16.1464C17.0488 16.3417 17.0488 16.6583 16.8536 16.8536C16.68 17.0271 16.4106 17.0464 16.2157 16.9114L16.1464 16.8536L12.0196 12.7266C11.0659 13.5217 9.83879 14 8.5 14C5.46243 14 3 11.5376 3 8.5C3 5.46243 5.46243 3 8.5 3ZM8.5 4C6.01472 4 4 6.01472 4 8.5C4 10.9853 6.01472 13 8.5 13C10.9853 13 13 10.9853 13 8.5C13 6.01472 10.9853 4 8.5 4Z"
fill="currentColor"
></path>
</svg>
)
export const zoomOutIcon = (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M11 8C11.2761 8 11.5 8.22386 11.5 8.5C11.5 8.77614 11.2761 9 11 9H6C5.72386 9 5.5 8.77614 5.5 8.5C5.5 8.22386 5.72386 8 6 8H11Z"
fill="currentColor"
></path>
<path
d="M14 8.5C14 5.46243 11.5376 3 8.5 3C5.46243 3 3 5.46243 3 8.5C3 11.5376 5.46243 14 8.5 14C9.83879 14 11.0659 13.5217 12.0196 12.7266L16.1464 16.8536L16.2157 16.9114C16.4106 17.0464 16.68 17.0271 16.8536 16.8536C17.0488 16.6583 17.0488 16.3417 16.8536 16.1464L12.7266 12.0196C13.5217 11.0659 14 9.83879 14 8.5ZM4 8.5C4 6.01472 6.01472 4 8.5 4C10.9853 4 13 6.01472 13 8.5C13 10.9853 10.9853 13 8.5 13C6.01472 13 4 10.9853 4 8.5Z"
fill="currentColor"
></path>
</svg>
)
export const prevIcon = (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6 5C5.75454 5 5.55039 5.17688 5.50806 5.41012L5.5 5.5V14.5C5.5 14.7761 5.72386 15 6 15C6.24546 15 6.44961 14.8231 6.49194 14.5899L6.5 14.5V5.5C6.5 5.22386 6.27614 5 6 5ZM13.8536 5.14645C13.68 4.97288 13.4106 4.9536 13.2157 5.08859L13.1464 5.14645L8.64645 9.64645C8.47288 9.82001 8.4536 10.0894 8.58859 10.2843L8.64645 10.3536L13.1464 14.8536C13.3417 15.0488 13.6583 15.0488 13.8536 14.8536C14.0271 14.68 14.0464 14.4106 13.9114 14.2157L13.8536 14.1464L9.70711 10L13.8536 5.85355C14.0488 5.65829 14.0488 5.34171 13.8536 5.14645Z"
fill="currentColor"
></path>
</svg>
)
export const nextIcon = (
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.5 5C13.7455 5 13.9496 5.17688 13.9919 5.41012L14 5.5V14.5C14 14.7761 13.7761 15 13.5 15C13.2545 15 13.0504 14.8231 13.0081 14.5899L13 14.5V5.5C13 5.22386 13.2239 5 13.5 5ZM5.64645 5.14645C5.82001 4.97288 6.08944 4.9536 6.28431 5.08859L6.35355 5.14645L10.8536 9.64645C11.0271 9.82001 11.0464 10.0894 10.9114 10.2843L10.8536 10.3536L6.35355 14.8536C6.15829 15.0488 5.84171 15.0488 5.64645 14.8536C5.47288 14.68 5.4536 14.4106 5.58859 14.2157L5.64645 14.1464L9.79289 10L5.64645 5.85355C5.45118 5.65829 5.45118 5.34171 5.64645 5.14645Z"
fill="currentColor"
></path>
</svg>
)

View File

@ -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;

View File

@ -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
}
}
}

View File

@ -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,