mirror of https://github.com/elk-zone/elk
287 lines
7.7 KiB
Vue
287 lines
7.7 KiB
Vue
<script setup lang="ts">
|
|
import type { Vector2 } from '@vueuse/gesture'
|
|
import { useGesture } from '@vueuse/gesture'
|
|
import { useReducedMotion } from '@vueuse/motion'
|
|
import type { mastodon } from 'masto'
|
|
|
|
const { media = [] } = defineProps<{
|
|
media?: mastodon.v1.MediaAttachment[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(event: 'close'): void
|
|
}>()
|
|
|
|
const modelValue = defineModel<number>({ required: true })
|
|
|
|
const slideGap = 20
|
|
const doubleTapThreshold = 250
|
|
|
|
const view = ref()
|
|
const slider = ref()
|
|
const slide = ref()
|
|
const image = ref()
|
|
|
|
const reduceMotion = import.meta.server ? ref(false) : useReducedMotion()
|
|
const isInitialScrollDone = useTimeout(350)
|
|
const canAnimate = computed(() => isInitialScrollDone.value && !reduceMotion.value)
|
|
|
|
const scale = ref(1)
|
|
const x = ref(0)
|
|
const y = ref(0)
|
|
|
|
const isDragging = ref(false)
|
|
const isPinching = ref(false)
|
|
|
|
const maxZoomOut = ref(1)
|
|
const isZoomedIn = computed(() => scale.value > 1)
|
|
|
|
const enableAutoplay = usePreferences('enableAutoplay')
|
|
|
|
function goToFocusedSlide() {
|
|
scale.value = 1
|
|
x.value = slide.value[modelValue.value].offsetLeft * scale.value
|
|
y.value = 0
|
|
}
|
|
|
|
onMounted(() => {
|
|
const slideGapAsScale = slideGap / view.value.clientWidth
|
|
maxZoomOut.value = 1 - slideGapAsScale
|
|
|
|
goToFocusedSlide()
|
|
})
|
|
watch(modelValue, goToFocusedSlide)
|
|
|
|
let lastOrigin = [0, 0]
|
|
let initialScale = 0
|
|
useGesture({
|
|
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
|
|
},
|
|
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: view,
|
|
eventOptions: {
|
|
passive: false,
|
|
},
|
|
})
|
|
|
|
const shiftRestrictions = computed(() => {
|
|
const focusedImage = image.value[modelValue.value]
|
|
const focusedSlide = slide.value[modelValue.value]
|
|
|
|
const scaledImageWidth = focusedImage.offsetWidth * scale.value
|
|
const scaledHorizontalOverflow = scaledImageWidth / 2 - view.value.clientWidth / 2 + slideGap
|
|
const horizontalOverflow = Math.max(0, scaledHorizontalOverflow / scale.value)
|
|
|
|
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,
|
|
}
|
|
})
|
|
|
|
function handlePinchZoom(initialScale: number, initialDistance: number, distance: number, [originX, originY]: Vector2) {
|
|
scale.value = initialScale * (distance / initialDistance)
|
|
scale.value = Math.max(maxZoomOut.value, scale.value)
|
|
|
|
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 < doubleTapThreshold
|
|
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 than 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="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
|
|
>
|
|
<component
|
|
:is="item.type === 'gifv' ? 'video' : 'img'"
|
|
ref="image"
|
|
:autoplay="enableAutoplay"
|
|
controls
|
|
loop
|
|
select-none
|
|
max-w-full
|
|
max-h-full
|
|
:style="imageStyle"
|
|
:draggable="false"
|
|
:src="item.url || item.previewUrl"
|
|
:alt="item.description || ''"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|