feat: add image preview swipe (#749)
parent
710511e589
commit
4354dd6a2e
|
@ -1,32 +1,14 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useImageGesture } from '~/composables/gestures'
|
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
const img = ref()
|
const locked = useScrollLock(document.body)
|
||||||
|
|
||||||
|
// Use to avoid strange error when directlying assigning to v-model on ModelMediaPreviewCarousel
|
||||||
|
const index = mediaPreviewIndex
|
||||||
|
|
||||||
const current = computed(() => mediaPreviewList.value[mediaPreviewIndex.value])
|
const current = computed(() => mediaPreviewList.value[mediaPreviewIndex.value])
|
||||||
const hasNext = computed(() => mediaPreviewIndex.value < mediaPreviewList.value.length - 1)
|
const hasNext = computed(() => index.value < mediaPreviewList.value.length - 1)
|
||||||
const hasPrev = computed(() => mediaPreviewIndex.value > 0)
|
const hasPrev = computed(() => index.value > 0)
|
||||||
|
|
||||||
useImageGesture(img, {
|
|
||||||
hasNext,
|
|
||||||
hasPrev,
|
|
||||||
onNext() {
|
|
||||||
if (hasNext.value)
|
|
||||||
mediaPreviewIndex.value++
|
|
||||||
},
|
|
||||||
onPrev() {
|
|
||||||
if (hasPrev.value)
|
|
||||||
mediaPreviewIndex.value--
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// stop global zooming
|
|
||||||
useEventListener('wheel', (evt) => {
|
|
||||||
if (evt.ctrlKey && (evt.deltaY < 0 || evt.deltaY > 0))
|
|
||||||
evt.preventDefault()
|
|
||||||
}, { passive: false })
|
|
||||||
|
|
||||||
const keys = useMagicKeys()
|
const keys = useMagicKeys()
|
||||||
|
|
||||||
|
@ -35,12 +17,12 @@ whenever(keys.arrowRight, next)
|
||||||
|
|
||||||
function next() {
|
function next() {
|
||||||
if (hasNext.value)
|
if (hasNext.value)
|
||||||
mediaPreviewIndex.value++
|
index.value++
|
||||||
}
|
}
|
||||||
|
|
||||||
function prev() {
|
function prev() {
|
||||||
if (hasPrev.value)
|
if (hasPrev.value)
|
||||||
mediaPreviewIndex.value--
|
index.value--
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(e: MouseEvent) {
|
function onClick(e: MouseEvent) {
|
||||||
|
@ -49,30 +31,31 @@ function onClick(e: MouseEvent) {
|
||||||
if (!el)
|
if (!el)
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => locked.value = true)
|
||||||
|
onUnmounted(() => locked.value = false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div relative h-full w-full flex pt-12 @click="onClick">
|
<div relative h-full w-full flex pt-12 w-100vh @click="onClick">
|
||||||
<button
|
<button
|
||||||
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.previous')"
|
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.previous')"
|
||||||
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" right-1
|
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" right-1 z5
|
||||||
:title="$t('action.next')" @click="next"
|
:title="$t('action.next')" @click="next"
|
||||||
>
|
>
|
||||||
<div i-ri:arrow-right-s-line text-white />
|
<div i-ri:arrow-right-s-line text-white />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="hasPrev" pointer-events-auto btn-action-icon bg="black/20" aria-label="action.next"
|
v-if="hasPrev" pointer-events-auto btn-action-icon bg="black/20" aria-label="action.next"
|
||||||
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" left-1
|
hover:bg="black/40" dark:bg="white/30" dark:hover:bg="white/20" absolute top="1/2" left-1 z5
|
||||||
:title="$t('action.prev')" @click="prev"
|
:title="$t('action.prev')" @click="prev"
|
||||||
>
|
>
|
||||||
<div i-ri:arrow-left-s-line text-white />
|
<div i-ri:arrow-left-s-line text-white />
|
||||||
</button>
|
</button>
|
||||||
<img
|
|
||||||
ref="img"
|
<div flex flex-row items-center mxa>
|
||||||
:src="current.url || current.previewUrl"
|
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
|
||||||
:alt="current.description || ''"
|
</div>
|
||||||
max-h-full max-w-full ma
|
|
||||||
>
|
|
||||||
|
|
||||||
<div absolute top-0 w-full flex justify-between>
|
<div absolute top-0 w-full flex justify-between>
|
||||||
<button
|
<button
|
||||||
|
@ -83,7 +66,7 @@ function onClick(e: MouseEvent) {
|
||||||
</button>
|
</button>
|
||||||
<div bg="black/30" dark:bg="white/10" ms-4 my-auto text-white rounded-full flex="~ center" overflow-hidden>
|
<div bg="black/30" dark:bg="white/10" ms-4 my-auto text-white rounded-full flex="~ center" overflow-hidden>
|
||||||
<div v-if="mediaPreviewList.length > 1" p="y-1 x-2" rounded-r-0 shrink-0>
|
<div v-if="mediaPreviewList.length > 1" p="y-1 x-2" rounded-r-0 shrink-0>
|
||||||
{{ mediaPreviewIndex + 1 }} / {{ mediaPreviewList.length }}
|
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-2" rounded-ie-full line-clamp-1
|
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-2" rounded-ie-full line-clamp-1
|
||||||
|
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SwipeDirection } from '@vueuse/core'
|
||||||
|
import { useReducedMotion } from '@vueuse/motion'
|
||||||
|
import type { Attachment } from 'masto'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{ media: Attachment[]; threshold?: number; modelValue: number }>(), {
|
||||||
|
media: [] as any,
|
||||||
|
threshold: 20,
|
||||||
|
modelValue: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', v: boolean): void
|
||||||
|
(event: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const target = ref()
|
||||||
|
const index = useVModel(props, 'modelValue', emit)
|
||||||
|
|
||||||
|
const animateTimeout = useTimeout(10)
|
||||||
|
const reduceMotion = useReducedMotion()
|
||||||
|
|
||||||
|
const canAnimate = computed(() => !reduceMotion.value && animateTimeout.value)
|
||||||
|
|
||||||
|
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 === SwipeDirection.RIGHT && Math.abs(distanceX.value) > props.threshold)
|
||||||
|
index.value = Math.max(0, index.value - 1)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
|
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > props.threshold)
|
||||||
|
index.value = Math.min(props.media.length - 1, index.value + 1)
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
||||||
|
if (direction === SwipeDirection.UP && Math.abs(distanceY.value) > props.threshold)
|
||||||
|
emit('close')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const distanceX = computed(() => {
|
||||||
|
if (width.value === 0)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if (!isSwiping.value || (direction.value !== SwipeDirection.LEFT && direction.value !== SwipeDirection.RIGHT))
|
||||||
|
return index.value * 100 * -1
|
||||||
|
|
||||||
|
return (lengthX.value / width.value) * 100 * -1 + (index.value * 100) * -1
|
||||||
|
})
|
||||||
|
|
||||||
|
const distanceY = computed(() => {
|
||||||
|
if (height.value === 0 || !isSwiping.value || direction.value !== SwipeDirection.UP)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return (lengthY.value / height.value) * 100 * -1
|
||||||
|
})
|
||||||
|
</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 place-items-center>
|
||||||
|
<img max-h-full max-w-full :draggable="false" select-none :src="item.url || item.previewUrl" :alt="item.description || ''">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,5 +1,7 @@
|
||||||
html {
|
html {
|
||||||
font-size: var(--font-size, 15px);
|
font-size: var(--font-size, 15px);
|
||||||
|
width: 100%;
|
||||||
|
width: 100vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
Loading…
Reference in New Issue