forked from Mirrors/elk
feat: media preview modal - better zoom support (#2133)
This commit is contained in:
parent
58f3ff6cd6
commit
a94fe1c9d0
2 changed files with 246 additions and 71 deletions
|
@ -53,11 +53,10 @@ 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>
|
||||
<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 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>
|
||||
|
@ -69,7 +68,6 @@ onUnmounted(() => locked.value = false)
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div absolute top-0 w-full flex justify-end>
|
||||
<button
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in a new issue