Files
FileSharing/src/components/FileContextMenu.vue
2026-05-09 18:09:57 +00:00

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>