204 lines
5.2 KiB
Vue
204 lines
5.2 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onUnmounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { useApp } from '@/composables/useApp'
|
|
import {
|
|
FolderOpen,
|
|
Download,
|
|
FileArchive,
|
|
Pencil,
|
|
Trash2,
|
|
Eye,
|
|
} from 'lucide-vue-next'
|
|
|
|
const props = defineProps<{
|
|
itemPath: string
|
|
itemName: string
|
|
itemType: 'file' | 'directory'
|
|
mimeType: string
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'rename'): void
|
|
(e: 'preview'): void
|
|
(e: 'download'): void
|
|
(e: 'downloadZip'): void
|
|
(e: 'delete'): void
|
|
(e: 'open'): void
|
|
}>()
|
|
|
|
const app = useApp()
|
|
const router = useRouter()
|
|
|
|
const menuOpen = ref(false)
|
|
const menuX = ref(0)
|
|
const menuY = ref(0)
|
|
|
|
const longPressTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
|
const didLongPress = ref(false)
|
|
const touchMoved = ref(false)
|
|
|
|
function canPreview() {
|
|
return props.mimeType.startsWith('image/') || props.mimeType.startsWith('video/') || props.mimeType.startsWith('audio/') || props.mimeType === 'application/pdf'
|
|
}
|
|
|
|
function showMenu(event: MouseEvent) {
|
|
const wrapper = menuWrapper.value
|
|
if (wrapper) {
|
|
const rect = wrapper.getBoundingClientRect()
|
|
menuX.value = event.clientX || rect.left
|
|
menuY.value = event.clientY || rect.bottom
|
|
} else {
|
|
menuX.value = event.clientX || 0
|
|
menuY.value = event.clientY || 0
|
|
}
|
|
menuOpen.value = true
|
|
setTimeout(() => {
|
|
document.addEventListener('click', closeMenu, { once: true })
|
|
}, 0)
|
|
}
|
|
|
|
function closeMenu() {
|
|
menuOpen.value = false
|
|
document.removeEventListener('click', closeMenu)
|
|
}
|
|
|
|
function doAction(fn: () => void) {
|
|
closeMenu()
|
|
fn()
|
|
}
|
|
|
|
function handleClick() {
|
|
if (didLongPress.value) {
|
|
didLongPress.value = false
|
|
return
|
|
}
|
|
emit('open')
|
|
}
|
|
|
|
function handleContextMenu(e: MouseEvent) {
|
|
e.preventDefault()
|
|
showMenu(e)
|
|
}
|
|
|
|
function handleTouchStart(e: TouchEvent) {
|
|
touchMoved.value = false
|
|
didLongPress.value = false
|
|
longPressTimer.value = setTimeout(() => {
|
|
didLongPress.value = true
|
|
const touch = e.touches[0] || e.changedTouches[0]
|
|
showMenu(new MouseEvent('contextmenu', { clientX: touch.clientX, clientY: touch.clientY }))
|
|
}, 500)
|
|
}
|
|
|
|
function handleTouchMove() {
|
|
touchMoved.value = true
|
|
if (longPressTimer.value) {
|
|
clearTimeout(longPressTimer.value)
|
|
longPressTimer.value = null
|
|
}
|
|
}
|
|
|
|
function handleTouchEnd(e: TouchEvent) {
|
|
if (longPressTimer.value) {
|
|
clearTimeout(longPressTimer.value)
|
|
longPressTimer.value = null
|
|
}
|
|
if (!touchMoved.value && !didLongPress.value) {
|
|
e.preventDefault()
|
|
emit('open')
|
|
}
|
|
}
|
|
|
|
function clampX(x: number) {
|
|
return Math.min(x, typeof window !== 'undefined' ? window.innerWidth - 190 : 200)
|
|
}
|
|
function clampY(y: number) {
|
|
return Math.min(y, typeof window !== 'undefined' ? window.innerHeight - 250 : 200)
|
|
}
|
|
|
|
onUnmounted(() => {
|
|
if (longPressTimer.value) clearTimeout(longPressTimer.value)
|
|
})
|
|
|
|
const menuWrapper = ref<HTMLElement>()
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="menuWrapper"
|
|
@click="handleClick"
|
|
@contextmenu="handleContextMenu"
|
|
@touchstart.passive="handleTouchStart"
|
|
@touchmove.passive="handleTouchMove"
|
|
@touchend="handleTouchEnd"
|
|
>
|
|
<slot />
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<div
|
|
v-if="menuOpen"
|
|
class="fixed z-[100] min-w-[180px] bg-card border border-border rounded-lg shadow-xl py-1"
|
|
:style="{ left: clampX(menuX) + 'px', top: clampY(menuY) + 'px' }"
|
|
@click.stop
|
|
>
|
|
<button
|
|
v-if="itemType === 'directory'"
|
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
|
@click="doAction(() => router.push({ query: { path: itemPath } }))"
|
|
>
|
|
<FolderOpen class="size-3.5 shrink-0" />
|
|
Open
|
|
</button>
|
|
|
|
<button
|
|
v-if="itemType === 'directory'"
|
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
|
@click="doAction(() => emit('downloadZip'))"
|
|
>
|
|
<FileArchive class="size-3.5 shrink-0" />
|
|
Download as ZIP
|
|
</button>
|
|
|
|
<button
|
|
v-else
|
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
|
@click="doAction(() => emit('download'))"
|
|
>
|
|
<Download class="size-3.5 shrink-0" />
|
|
Download
|
|
</button>
|
|
|
|
<button
|
|
v-if="itemType === 'file' && canPreview()"
|
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
|
@click="doAction(() => emit('preview'))"
|
|
>
|
|
<Eye class="size-3.5 shrink-0" />
|
|
Preview
|
|
</button>
|
|
|
|
<div class="h-px bg-border my-0.5 mx-2" />
|
|
|
|
<button
|
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-muted/70 transition-colors text-left"
|
|
@click="doAction(() => emit('rename'))"
|
|
>
|
|
<Pencil class="size-3.5 shrink-0" />
|
|
Rename
|
|
</button>
|
|
|
|
<div class="h-px bg-border my-0.5 mx-2" />
|
|
|
|
<button
|
|
class="w-full flex items-center gap-2.5 px-3 py-2 text-sm hover:bg-destructive/10 text-destructive transition-colors text-left"
|
|
@click="doAction(() => emit('delete'))"
|
|
>
|
|
<Trash2 class="size-3.5 shrink-0" />
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|