263 lines
9.7 KiB
Vue
263 lines
9.7 KiB
Vue
<template>
|
|
<Teleport to="body">
|
|
<div
|
|
v-show="isOpen"
|
|
ref="modalRef"
|
|
:style="{
|
|
position: 'fixed',
|
|
left: isFullScreen ? '0' : `${position.x}px`,
|
|
top: isFullScreen ? '0' : `${position.y}px`,
|
|
width: isFullScreen ? '100%' : `${size.width}px`,
|
|
height: isFullScreen ? '100%' : `${size.height}px`,
|
|
}"
|
|
class="bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 shadow-2xl flex flex-col fixed z-50 transition-colors duration-300"
|
|
:class="{ 'rounded-none border-0': isFullScreen, 'select-none': isDragging }"
|
|
>
|
|
<!-- Header with drag handle -->
|
|
<div class="flex justify-between items-center px-5 py-4 border-b border-gray-100 dark:border-gray-700/50" :class="{ 'cursor-move': !isFullScreen && !isMobile }" @mousedown="startDrag" @touchstart="handleTouchStart">
|
|
<h3 class="text-lg sm:text-lg font-bold text-gray-800 dark:text-gray-100 truncate pr-2">{{ title }}</h3>
|
|
<div class="flex items-center space-x-1">
|
|
<button @click="toggleFullScreen" @mousedown.stop @touchstart.stop class="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-all" data-rybbit-event="modal-fullscreen">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-if="!isFullScreen">
|
|
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
|
</svg>
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-else>
|
|
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 1 2-2h3M3 16h3a2 2 0 0 1 2 2v3" />
|
|
</svg>
|
|
</button>
|
|
<button @click="close" @mousedown.stop @touchstart.stop class="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all" data-rybbit-event="modal-close">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div :class="[noPadding ? '' : 'p-5 sm:p-6', 'flex-1 overflow-auto']">
|
|
<slot></slot>
|
|
</div>
|
|
|
|
<!-- Resize handle -->
|
|
<div v-if="!isFullScreen && !isMobile" class="absolute bottom-0 right-0 w-6 h-6 cursor-se-resize flex items-end justify-end p-1 opacity-50 hover:opacity-100 transition-opacity" @mousedown="startResize" @touchstart="startResize">
|
|
<svg class="w-4 h-4 text-gray-400 dark:text-gray-500" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M22 22H20V20H22V22ZM22 18H20V16H22V18ZM18 22H16V20H18V22Z" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
|
|
|
|
const props = defineProps<{
|
|
isOpen: boolean;
|
|
title: string;
|
|
initialWidth?: number;
|
|
initialHeight?: number;
|
|
noPadding?: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'close'): void;
|
|
}>();
|
|
|
|
const modalRef = ref<HTMLElement | null>(null);
|
|
const position = ref({ x: 0, y: 0 });
|
|
const size = ref({
|
|
width: props.initialWidth || 800,
|
|
height: props.initialHeight || 400,
|
|
});
|
|
|
|
const isDragging = ref(false);
|
|
const isResizing = ref(false);
|
|
const startPos = ref({ x: 0, y: 0 });
|
|
const startSize = ref({ width: 0, height: 0 });
|
|
|
|
// Add isFullScreen ref
|
|
const isFullScreen = ref(false);
|
|
const isMobile = ref(false);
|
|
|
|
// Add previous state storage for restoring from full screen
|
|
const previousState = ref({
|
|
position: { x: 0, y: 0 },
|
|
size: { width: 0, height: 0 },
|
|
});
|
|
|
|
// Check if device is mobile
|
|
const checkMobile = () => {
|
|
isMobile.value = window.innerWidth < 640; // sm breakpoint in Tailwind
|
|
|
|
// Auto fullscreen on mobile
|
|
if (isMobile.value && !isFullScreen.value) {
|
|
toggleFullScreen();
|
|
} else if (!isMobile.value && isFullScreen.value && autoFullScreened.value) {
|
|
// If we're no longer on mobile and were auto-fullscreened, exit fullscreen
|
|
toggleFullScreen();
|
|
autoFullScreened.value = false;
|
|
}
|
|
};
|
|
|
|
// Track if fullscreen was automatic (for mobile)
|
|
const autoFullScreened = ref(false);
|
|
|
|
// Add toggleFullScreen function
|
|
const toggleFullScreen = () => {
|
|
if (!isFullScreen.value) {
|
|
// Store current state before going full screen
|
|
previousState.value = {
|
|
position: { ...position.value },
|
|
size: { ...size.value },
|
|
};
|
|
|
|
// If toggling to fullscreen on mobile automatically, track it
|
|
if (isMobile.value) {
|
|
autoFullScreened.value = true;
|
|
}
|
|
} else {
|
|
// Restore previous state
|
|
position.value = { ...previousState.value.position };
|
|
size.value = { ...previousState.value.size };
|
|
}
|
|
isFullScreen.value = !isFullScreen.value;
|
|
};
|
|
|
|
// Unified start function for both drag and resize
|
|
const startAction = (event: MouseEvent | TouchEvent, action: 'drag' | 'resize') => {
|
|
if (isFullScreen.value) return;
|
|
|
|
// Extract the correct coordinates based on event type
|
|
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
|
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
|
|
|
if (action === 'drag') {
|
|
isDragging.value = true;
|
|
startPos.value = {
|
|
x: clientX - position.value.x,
|
|
y: clientY - position.value.y,
|
|
};
|
|
} else {
|
|
isResizing.value = true;
|
|
startPos.value = { x: clientX, y: clientY };
|
|
startSize.value = { ...size.value };
|
|
}
|
|
|
|
document.addEventListener('mousemove', handleMove);
|
|
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
|
document.addEventListener('mouseup', stopAction);
|
|
document.addEventListener('touchend', stopAction);
|
|
};
|
|
|
|
const startDrag = (event: MouseEvent | TouchEvent) => startAction(event, 'drag');
|
|
const startResize = (event: MouseEvent | TouchEvent) => startAction(event, 'resize');
|
|
|
|
const handleMove = (event: MouseEvent | TouchEvent) => {
|
|
if (!isDragging.value && !isResizing.value) return;
|
|
|
|
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX;
|
|
const clientY = 'touches' in event ? event.touches[0].clientY : event.clientY;
|
|
|
|
if (isDragging.value) {
|
|
const newX = clientX - startPos.value.x;
|
|
const newY = clientY - startPos.value.y;
|
|
position.value = constrainPosition(newX, newY);
|
|
} else if (isResizing.value) {
|
|
const deltaX = clientX - startPos.value.x;
|
|
const deltaY = clientY - startPos.value.y;
|
|
size.value = constrainSize(startSize.value.width + deltaX, startSize.value.height + deltaY);
|
|
}
|
|
};
|
|
|
|
const stopAction = () => {
|
|
isDragging.value = false;
|
|
isResizing.value = false;
|
|
document.removeEventListener('mousemove', handleMove);
|
|
document.removeEventListener('touchmove', handleTouchMove, false); // Fix: ensure matching capture option
|
|
document.removeEventListener('mouseup', stopAction);
|
|
document.removeEventListener('touchend', stopAction);
|
|
};
|
|
|
|
const constrainPosition = (x: number, y: number) => {
|
|
if (!modalRef.value) return { x, y };
|
|
const modalRect = modalRef.value.getBoundingClientRect();
|
|
return {
|
|
x: Math.max(0, Math.min(x, window.innerWidth - modalRect.width)),
|
|
y: Math.max(0, Math.min(y, window.innerHeight - modalRect.height)),
|
|
};
|
|
};
|
|
|
|
const constrainSize = (width: number, height: number) => {
|
|
return {
|
|
width: Math.max(300, Math.min(width, window.innerWidth - position.value.x)),
|
|
height: Math.max(200, Math.min(height, window.innerHeight - position.value.y)),
|
|
};
|
|
};
|
|
|
|
const centerModal = () => {
|
|
if (!modalRef.value) return;
|
|
position.value = {
|
|
x: (window.innerWidth - size.value.width) / 2,
|
|
y: (window.innerHeight - size.value.height) / 2,
|
|
};
|
|
};
|
|
|
|
const close = () => {
|
|
emit('close');
|
|
position.value = { x: 0, y: 0 };
|
|
};
|
|
|
|
// Event handlers
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key === 'Escape' && props.isOpen) close();
|
|
};
|
|
|
|
const handleResize = () => {
|
|
if (!isDragging.value && !isResizing.value) {
|
|
position.value = constrainPosition(position.value.x, position.value.y);
|
|
size.value = constrainSize(size.value.width, size.value.height);
|
|
}
|
|
};
|
|
|
|
// Add these new touch handling functions
|
|
const handleTouchStart = (event: TouchEvent) => {
|
|
if (isFullScreen.value) return;
|
|
if (event.touches.length === 1) {
|
|
startAction(event, 'drag');
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (event: TouchEvent) => {
|
|
if (!isDragging.value && !isResizing.value) return;
|
|
event.preventDefault(); // Prevent scrolling while dragging
|
|
handleMove(event);
|
|
};
|
|
|
|
// Lifecycle
|
|
watch(
|
|
() => props.isOpen,
|
|
newValue => {
|
|
if (newValue) nextTick(centerModal);
|
|
}
|
|
);
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
window.addEventListener('resize', handleResize);
|
|
window.addEventListener('resize', checkMobile);
|
|
|
|
// Initial check for mobile
|
|
checkMobile();
|
|
|
|
if (props.isOpen) centerModal();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('keydown', handleKeyDown);
|
|
window.removeEventListener('resize', handleResize);
|
|
window.removeEventListener('resize', checkMobile);
|
|
stopAction();
|
|
});
|
|
</script>
|