feat: media preview modal - better zoom support (#2133)

This commit is contained in:
Horváth Bálint 2023-06-12 20:46:53 +02:00 committed by GitHub
parent 58f3ff6cd6
commit a94fe1c9d0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 246 additions and 71 deletions

View file

@ -53,21 +53,19 @@ onUnmounted(() => locked.value = false)
<div i-ri:arrow-left-s-line text-white />
</button>
<div flex flex-row items-center mxa>
<div flex="~ col center" max-h-full max-w-full>
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
<div flex="~ col center" h-full w-full>
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
<div bg="black/30" dark:bg="white/10" ms-4 mb-6 mt-4 text-white rounded-full flex="~ center shrink-0" overflow-hidden>
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
{{ index + 1 }} / {{ mediaPreviewList.length }}
</div>
<p
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-3" rounded-ie-full line-clamp-1
ws-pre-wrap break-all :title="current.description" w-full
>
{{ current.description }}
</p>
<div bg="black/30" dark:bg="white/10" mb-6 mt-4 text-white rounded-full flex="~ center shrink-0" overflow-hidden>
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
{{ index + 1 }} / {{ mediaPreviewList.length }}
</div>
<p
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-3" rounded-ie-full line-clamp-1
ws-pre-wrap break-all :title="current.description" w-full
>
{{ current.description }}
</p>
</div>
</div>

View file

@ -1,12 +1,11 @@
<script setup lang="ts">
import type { Vector2 } from '@vueuse/gesture'
import { useGesture } from '@vueuse/gesture'
import type { PermissiveMotionProperties } from '@vueuse/motion'
import { useReducedMotion } from '@vueuse/motion'
import type { mastodon } from 'masto'
const { media = [], threshold = 20 } = defineProps<{
const { media = [] } = defineProps<{
media?: mastodon.v1.MediaAttachment[]
threshold?: number
}>()
const emit = defineEmits<{
@ -17,88 +16,266 @@ const { modelValue } = defineModels<{
modelValue: number
}>()
const target = ref()
const slideGap = 20
const doubleTapTreshold = 250
const view = ref()
const slider = ref()
const slide = ref()
const image = ref()
const animateTimeout = useTimeout(10)
const reduceMotion = process.server ? ref(false) : useReducedMotion()
const isInitialScrollDone = useTimeout(350)
const canAnimate = computed(() => isInitialScrollDone.value && !reduceMotion.value)
const canAnimate = computed(() => !reduceMotion.value && animateTimeout.value)
const scale = ref(1)
const x = ref(0)
const y = ref(0)
const { motionProperties } = useMotionProperties(target, {
cursor: 'grab',
scale: 1,
x: 0,
y: 0,
})
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
const isDragging = ref(false)
const isPinching = ref(false)
function resetZoom() {
set({ scale: 1 })
const maxZoomOut = ref(1)
const isZoomedIn = computed(() => scale.value > 1)
function goToFocusedSlide() {
scale.value = 1
x.value = slide.value[modelValue.value].offsetLeft * scale.value
y.value = 0
}
watch(modelValue, resetZoom)
onMounted(() => {
const slideGapAsScale = slideGap / view.value.clientWidth
maxZoomOut.value = 1 - slideGapAsScale
const { width, height } = useElementSize(target)
const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
threshold: 5,
passive: false,
onSwipeEnd(e, direction) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (direction === 'right' && Math.abs(distanceX.value) > threshold) {
modelValue.value = Math.max(0, modelValue.value - 1)
resetZoom()
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (direction === 'left' && Math.abs(distanceX.value) > threshold) {
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
resetZoom()
}
// eslint-disable-next-line @typescript-eslint/no-use-before-define
if (direction === 'up' && Math.abs(distanceY.value) > threshold)
emit('close')
},
goToFocusedSlide()
})
watch(modelValue, goToFocusedSlide)
let lastOrigin = [0, 0]
let initialScale = 0
useGesture({
onPinch({ offset: [distance, _angle] }) {
set({ scale: Math.max(0.5, 1 + distance / 200) })
onPinch({ first, initial: [initialDistance], movement: [deltaDistance], da: [distance], origin, touches }) {
isPinching.value = true
if (first) {
initialScale = scale.value
}
else {
if (touches === 0)
handleMouseWheelZoom(initialScale, deltaDistance, origin)
else
handlePinchZoom(initialScale, initialDistance, distance, origin)
}
lastOrigin = origin
},
onMove({ movement: [x, y], dragging, pinching }) {
if (dragging && !pinching)
set({ x, y })
onPinchEnd() {
isPinching.value = false
isDragging.value = false
if (!isZoomedIn.value)
goToFocusedSlide()
},
onDrag({ movement, delta, pinching, tap, last, swipe, event, xy }) {
event.preventDefault()
if (pinching)
return
if (last)
handleLastDrag(tap, swipe, movement, xy)
else
handleDrag(delta, movement)
},
}, {
domTarget: target,
domTarget: view,
eventOptions: {
passive: true,
passive: false,
},
})
const distanceX = computed(() => {
if (width.value === 0)
return 0
const shiftRestrictions = computed(() => {
const focusedImage = image.value[modelValue.value]
const focusedSlide = slide.value[modelValue.value]
if (!isSwiping.value || (direction.value !== 'left' && direction.value !== 'right'))
return modelValue.value * 100 * -1
const scaledImageWidth = focusedImage.offsetWidth * scale.value
const scaledHorizontalOverflow = scaledImageWidth / 2 - view.value.clientWidth / 2 + slideGap
const horizontalOverflow = Math.max(0, scaledHorizontalOverflow / scale.value)
return (lengthX.value / width.value) * 100 * -1 + (modelValue.value * 100) * -1
const scaledImageHeight = focusedImage.offsetHeight * scale.value
const scaledVerticalOverflow = scaledImageHeight / 2 - view.value.clientHeight / 2 + slideGap
const verticalOverflow = Math.max(0, scaledVerticalOverflow / scale.value)
return {
left: focusedSlide.offsetLeft - horizontalOverflow,
right: focusedSlide.offsetLeft + horizontalOverflow,
top: focusedSlide.offsetTop - verticalOverflow,
bottom: focusedSlide.offsetTop + verticalOverflow,
}
})
const distanceY = computed(() => {
if (height.value === 0 || !isSwiping.value || direction.value !== 'up')
return 0
function handlePinchZoom(initialScale: number, initialDistance: number, distance: number, [originX, originY]: Vector2) {
scale.value = initialScale * (distance / initialDistance)
scale.value = Math.max(maxZoomOut.value, scale.value)
return (lengthY.value / height.value) * 100 * -1
const deltaCenterX = originX - lastOrigin[0]
const deltaCenterY = originY - lastOrigin[1]
handleZoomDrag([deltaCenterX, deltaCenterY])
}
function handleMouseWheelZoom(initialScale: number, deltaDistance: number, [originX, originY]: Vector2) {
scale.value = initialScale + (deltaDistance / 1000)
scale.value = Math.max(maxZoomOut.value, scale.value)
const deltaCenterX = lastOrigin[0] - originX
const deltaCenterY = lastOrigin[1] - originY
handleZoomDrag([deltaCenterX, deltaCenterY])
}
function handleLastDrag(tap: boolean, swipe: Vector2, movement: Vector2, position: Vector2) {
isDragging.value = false
if (tap)
handleTap(position)
else if (swipe[0] || swipe[1])
handleSwipe(swipe, movement)
else if (!isZoomedIn.value)
slideToClosestSlide()
}
let lastTapAt = 0
function handleTap([positionX, positionY]: Vector2) {
const now = Date.now()
const isDoubleTap = now - lastTapAt < doubleTapTreshold
lastTapAt = now
if (!isDoubleTap)
return
if (isZoomedIn.value) {
goToFocusedSlide()
}
else {
const focusedSlideBounding = slide.value[modelValue.value].getBoundingClientRect()
const slideCenterX = focusedSlideBounding.left + focusedSlideBounding.width / 2
const slideCenterY = focusedSlideBounding.top + focusedSlideBounding.height / 2
scale.value = 3
x.value += positionX - slideCenterX
y.value += positionY - slideCenterY
restrictShiftToInsideSlide()
}
}
function handleSwipe([horiz, vert]: Vector2, [movementX, movementY]: Vector2) {
if (isZoomedIn.value || isPinching.value)
return
const isHorizontalDrag = Math.abs(movementX) >= Math.abs(movementY)
if (isHorizontalDrag) {
if (horiz === 1) // left
modelValue.value = Math.max(0, modelValue.value - 1)
if (horiz === -1) // right
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
}
else if (vert === 1 || vert === -1) {
emit('close')
}
goToFocusedSlide()
}
function slideToClosestSlide() {
const startOfFocusedSlide = slide.value[modelValue.value].offsetLeft * scale.value
const slideWidth = slide.value[modelValue.value].offsetWidth * scale.value
if (x.value > startOfFocusedSlide + slideWidth / 2)
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
else if (x.value < startOfFocusedSlide - slideWidth / 2)
modelValue.value = Math.max(0, modelValue.value - 1)
goToFocusedSlide()
}
function handleDrag(delta: Vector2, movement: Vector2) {
isDragging.value = true
if (isZoomedIn.value)
handleZoomDrag(delta)
else
handleSlideDrag(movement)
}
function handleZoomDrag([deltaX, deltaY]: Vector2) {
x.value -= deltaX / scale.value
y.value -= deltaY / scale.value
restrictShiftToInsideSlide()
}
function handleSlideDrag([movementX, movementY]: Vector2) {
goToFocusedSlide()
if (Math.abs(movementY) > Math.abs(movementX)) // vertical movement is more then horizontal
y.value -= movementY / scale.value
else
x.value -= movementX / scale.value
if (media.length === 1)
x.value = 0
}
function restrictShiftToInsideSlide() {
x.value = Math.min(shiftRestrictions.value.right, Math.max(shiftRestrictions.value.left, x.value))
y.value = Math.min(shiftRestrictions.value.bottom, Math.max(shiftRestrictions.value.top, y.value))
}
const sliderStyle = computed(() => {
const style = {
transform: `scale(${scale.value}) translate(${-x.value}px, ${-y.value}px)`,
transition: 'none',
gap: `${slideGap}px`,
}
if (canAnimate.value && !isDragging.value && !isPinching.value)
style.transition = 'all 0.3s ease'
return style
})
const imageStyle = computed(() => ({
cursor: isDragging.value ? 'grabbing' : 'grab',
}))
</script>
<template>
<div ref="target" flex flex-row max-h-full max-w-full overflow-hidden>
<div flex :style="{ transform: `translateX(${distanceX}%) translateY(${distanceY}%)`, transition: isSwiping ? 'none' : canAnimate ? 'all 0.5s ease' : 'none' }">
<div v-for="item in media" :key="item.id" p4 select-none w-full flex-shrink-0 flex flex-col items-center justify-center>
<img max-h-full max-w-full :draggable="false" select-none :src="item.url || item.previewUrl" :alt="item.description || ''">
<div ref="view" flex flex-row h-full w-full overflow-hidden>
<div ref="slider" :style="sliderStyle" w-full h-full flex items-center>
<div
v-for="item in media"
:key="item.id"
ref="slide"
flex-shrink-0
w-full
h-full
flex
items-center
justify-center
>
<img
ref="image"
select-none
max-w-full
max-h-full
:style="imageStyle"
:draggable="false"
:src="item.url || item.previewUrl"
:alt="item.description || ''"
>
</div>
</div>
</div>