feat: media preview modal - better zoom support (#2133)
parent
58f3ff6cd6
commit
a94fe1c9d0
|
@ -53,11 +53,10 @@ onUnmounted(() => locked.value = false)
|
||||||
<div i-ri:arrow-left-s-line text-white />
|
<div i-ri:arrow-left-s-line text-white />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div flex flex-row items-center mxa>
|
<div flex="~ col center" h-full w-full>
|
||||||
<div flex="~ col center" max-h-full max-w-full>
|
|
||||||
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
|
<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>
|
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
|
||||||
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -69,7 +68,6 @@ onUnmounted(() => locked.value = false)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div absolute top-0 w-full flex justify-end>
|
<div absolute top-0 w-full flex justify-end>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Vector2 } from '@vueuse/gesture'
|
||||||
import { useGesture } from '@vueuse/gesture'
|
import { useGesture } from '@vueuse/gesture'
|
||||||
import type { PermissiveMotionProperties } from '@vueuse/motion'
|
|
||||||
import { useReducedMotion } from '@vueuse/motion'
|
import { useReducedMotion } from '@vueuse/motion'
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const { media = [], threshold = 20 } = defineProps<{
|
const { media = [] } = defineProps<{
|
||||||
media?: mastodon.v1.MediaAttachment[]
|
media?: mastodon.v1.MediaAttachment[]
|
||||||
threshold?: number
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -17,88 +16,266 @@ const { modelValue } = defineModels<{
|
||||||
modelValue: number
|
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 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, {
|
const isDragging = ref(false)
|
||||||
cursor: 'grab',
|
const isPinching = ref(false)
|
||||||
scale: 1,
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
})
|
|
||||||
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
|
|
||||||
|
|
||||||
function resetZoom() {
|
const maxZoomOut = ref(1)
|
||||||
set({ scale: 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)
|
goToFocusedSlide()
|
||||||
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')
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
watch(modelValue, goToFocusedSlide)
|
||||||
|
|
||||||
|
let lastOrigin = [0, 0]
|
||||||
|
let initialScale = 0
|
||||||
useGesture({
|
useGesture({
|
||||||
onPinch({ offset: [distance, _angle] }) {
|
onPinch({ first, initial: [initialDistance], movement: [deltaDistance], da: [distance], origin, touches }) {
|
||||||
set({ scale: Math.max(0.5, 1 + distance / 200) })
|
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 }) {
|
onPinchEnd() {
|
||||||
if (dragging && !pinching)
|
isPinching.value = false
|
||||||
set({ x, y })
|
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: {
|
eventOptions: {
|
||||||
passive: true,
|
passive: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const distanceX = computed(() => {
|
const shiftRestrictions = computed(() => {
|
||||||
if (width.value === 0)
|
const focusedImage = image.value[modelValue.value]
|
||||||
return 0
|
const focusedSlide = slide.value[modelValue.value]
|
||||||
|
|
||||||
if (!isSwiping.value || (direction.value !== 'left' && direction.value !== 'right'))
|
const scaledImageWidth = focusedImage.offsetWidth * scale.value
|
||||||
return modelValue.value * 100 * -1
|
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(() => {
|
function handlePinchZoom(initialScale: number, initialDistance: number, distance: number, [originX, originY]: Vector2) {
|
||||||
if (height.value === 0 || !isSwiping.value || direction.value !== 'up')
|
scale.value = initialScale * (distance / initialDistance)
|
||||||
return 0
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="target" flex flex-row max-h-full max-w-full overflow-hidden>
|
<div ref="view" flex flex-row h-full w-full overflow-hidden>
|
||||||
<div flex :style="{ transform: `translateX(${distanceX}%) translateY(${distanceY}%)`, transition: isSwiping ? 'none' : canAnimate ? 'all 0.5s ease' : 'none' }">
|
<div ref="slider" :style="sliderStyle" w-full h-full flex items-center>
|
||||||
<div v-for="item in media" :key="item.id" p4 select-none w-full flex-shrink-0 flex flex-col items-center justify-center>
|
<div
|
||||||
<img max-h-full max-w-full :draggable="false" select-none :src="item.url || item.previewUrl" :alt="item.description || ''">
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
Loading…
Reference in New Issue