Base model

This commit is contained in:
Dennis Postma 2025-04-04 03:29:34 +02:00
parent 100bacc471
commit a23b1c8463
5 changed files with 521 additions and 417 deletions

View File

@ -0,0 +1,219 @@
<template>
<!-- Base Modal Component -->
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none" v-if="modelValue">
<!-- Modal backdrop with semi-transparent background (only if showBackdrop is true) -->
<div v-if="showBackdrop" class="absolute inset-0 bg-black bg-opacity-50 pointer-events-auto" @click="closeModal"></div>
<!-- Modal content -->
<div class="bg-gray-800 rounded-lg overflow-auto shadow-lg pointer-events-auto relative" :class="[{ 'border border-gray-400': showBorder }, { 'max-w-2xl w-full max-h-[90vh]': !movable }, customClass]" :style="modalStyle" ref="modalRef">
<!-- Modal header -->
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600" :class="{ 'cursor-move': movable }" @mousedown="movable ? startDrag($event) : null">
<div class="flex items-center gap-2 text-lg font-semibold">
<slot name="header-icon">
<i class="fas fa-window-maximize text-blue-500"></i>
</slot>
<slot name="header-title">Modal</slot>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-white">
<i class="fas fa-times"></i>
</button>
</div>
<!-- Modal content -->
<div class="p-6">
<slot></slot>
</div>
<!-- Resize handle (only if resizable is true) -->
<div v-if="resizable" class="absolute bottom-0 right-0 w-4 h-4 cursor-se-resize" @mousedown="startResize">
<i class="fas fa-grip-lines-diagonal text-gray-500 text-xs absolute bottom-1 right-1"></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
// Props
const props = defineProps({
modelValue: {
type: Boolean,
required: true,
},
title: {
type: String,
default: 'Modal',
},
showBackdrop: {
type: Boolean,
default: true,
},
showBorder: {
type: Boolean,
default: false,
},
movable: {
type: Boolean,
default: false,
},
resizable: {
type: Boolean,
default: false,
},
initialPosition: {
type: Object,
default: () => ({ x: 0, y: 0 }),
},
initialSize: {
type: Object,
default: () => ({ width: 800, height: 600 }),
},
customClass: {
type: String,
default: '',
},
});
// Emits
const emit = defineEmits(['update:modelValue', 'close']);
// Refs
const modalRef = ref<HTMLElement | null>(null);
// State
const position = ref({
x: props.initialPosition.x,
y: props.initialPosition.y,
});
const size = ref({
width: props.initialSize.width,
height: props.initialSize.height,
});
const isDragging = ref(false);
const dragOffset = ref({ x: 0, y: 0 });
const isResizing = ref(false);
const resizeStart = ref({ x: 0, y: 0 });
const initialSize = ref({ width: 0, height: 0 });
// Computed
const modalStyle = computed(() => {
if (props.movable) {
return {
transform: `translate3d(${position.value.x}px, ${position.value.y}px, 0)`,
width: `${size.value.width}px`,
height: `${size.value.height}px`,
maxWidth: '90vw',
maxHeight: '90vh',
};
}
return {};
});
// Methods
const closeModal = () => {
emit('update:modelValue', false);
emit('close');
};
const startDrag = (e: MouseEvent) => {
// Only start dragging if it's the header (not a button in the header)
if ((e.target as HTMLElement).tagName === 'BUTTON' || (e.target as HTMLElement).closest('button')) {
return;
}
isDragging.value = true;
dragOffset.value = {
x: e.clientX - position.value.x,
y: e.clientY - position.value.y,
};
document.addEventListener('mousemove', handleDrag);
document.addEventListener('mouseup', stopDrag);
};
const handleDrag = (e: MouseEvent) => {
if (!isDragging.value) return;
position.value = {
x: e.clientX - dragOffset.value.x,
y: e.clientY - dragOffset.value.y,
};
};
const stopDrag = () => {
isDragging.value = false;
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', stopDrag);
};
const startResize = (e: MouseEvent) => {
isResizing.value = true;
resizeStart.value = { x: e.clientX, y: e.clientY };
initialSize.value = { ...size.value };
document.addEventListener('mousemove', handleResize);
document.addEventListener('mouseup', stopResize);
};
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - resizeStart.value.x;
const deltaY = e.clientY - resizeStart.value.y;
size.value = {
width: Math.max(300, initialSize.value.width + deltaX),
height: Math.max(200, initialSize.value.height + deltaY),
};
};
const stopResize = () => {
isResizing.value = false;
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
};
// Center the modal in the viewport when it opens
const centerModal = () => {
if (props.movable && props.modelValue) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
position.value = {
x: (viewportWidth - size.value.width) / 2,
y: (viewportHeight - size.value.height) / 2,
};
}
};
// Lifecycle hooks
onMounted(() => {
if (props.movable) {
centerModal();
}
});
onBeforeUnmount(() => {
document.removeEventListener('mousemove', handleDrag);
document.removeEventListener('mouseup', stopDrag);
document.removeEventListener('mousemove', handleResize);
document.removeEventListener('mouseup', stopResize);
});
// Watch for changes in modelValue to center the modal when it opens
watch(
() => props.modelValue,
newValue => {
if (newValue && props.movable) {
centerModal();
}
}
);
// Expose methods
defineExpose({
closeModal,
centerModal,
});
</script>

View File

@ -1,20 +1,9 @@
<template>
<!-- Help Modal -->
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none" v-if="isModalOpen">
<!-- Modal backdrop with semi-transparent background -->
<div class="absolute inset-0 bg-black bg-opacity-50 pointer-events-auto" @click="closeModal"></div>
<!-- Modal content -->
<div class="bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-auto shadow-lg pointer-events-auto relative">
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
<div class="flex items-center gap-2 text-lg font-semibold">
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true">
<template #header-icon>
<i class="fas fa-question-circle text-blue-500"></i>
<span>Help</span>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-white">
<i class="fas fa-times"></i>
</button>
</div>
</template>
<template #header-title>Help</template>
<div class="p-6">
<!-- Help content tabs -->
@ -76,16 +65,21 @@
</div>
</div>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import BaseModal from './BaseModal.vue';
const store = useSpritesheetStore();
const isModalOpen = computed(() => store.isHelpModalOpen.value);
const isModalOpen = computed({
get: () => store.isHelpModalOpen.value,
set: value => {
store.isHelpModalOpen.value = value;
},
});
const activeTab = ref('shortcuts');
const tabs = [

View File

@ -1,27 +1,9 @@
<template>
<!-- Make the outer container always pointer-events-none so clicks pass through -->
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none">
<!-- Apply pointer-events-auto ONLY to the modal itself so it can be interacted with -->
<div
class="bg-gray-800 rounded-lg overflow-auto scrollbar-hide shadow-lg pointer-events-auto relative border border-gray-400"
:class="{ invisible: !isModalOpen, visible: isModalOpen }"
:style="{
transform: `translate3d(${position.x}px, ${position.y + (isModalOpen ? 0 : -20)}px, 0)`,
width: `${modalSize.width}px`,
height: `${modalSize.height}px`,
maxWidth: '90vw',
maxHeight: '90vh',
}"
>
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600 cursor-move" @mousedown="startDrag">
<div class="flex items-center gap-2 text-lg font-semibold select-none">
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true" :initialSize="modalSize" customClass="scrollbar-hide">
<template #header-icon>
<i class="fas fa-film text-blue-500"></i>
<span>Animation Preview</span>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-gray-200 text-xl">
<i class="fas fa-times"></i>
</button>
</div>
</template>
<template #header-title>Animation Preview</template>
<div class="p-6 flex flex-col h-[calc(100%-64px)]">
<div class="flex flex-wrap items-center gap-4 mb-6">
@ -139,31 +121,29 @@
</div>
</div>
</div>
<!-- Resize handle - larger and more noticeable -->
<div class="absolute bottom-0 right-0 w-8 h-8 cursor-se-resize flex items-end justify-end bg-gradient-to-br from-transparent to-gray-700 hover:to-blue-500 transition-colors duration-200" @mousedown="startResize">
<i class="fas fa-grip-lines-diagonal text-gray-400 hover:text-white p-1"></i>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import BaseModal from './BaseModal.vue';
const store = useSpritesheetStore();
const animCanvas = ref<HTMLCanvasElement | null>(null);
const isModalOpen = computed(() => store.isModalOpen.value);
const isModalOpen = computed({
get: () => store.isModalOpen.value,
set: value => {
store.isModalOpen.value = value;
},
});
const sprites = computed(() => store.sprites.value);
const animation = computed(() => store.animation);
const previewBorder = computed(() => store.previewBorder);
const currentFrame = ref(0);
const position = ref({ x: 0, y: 0 });
const isDragging = ref(false);
const dragOffset = ref({ x: 0, y: 0 });
// Position is now handled by BaseModal
// New state for added features
const previewZoom = ref(1);
@ -197,11 +177,8 @@
const isViewportDragging = ref(false);
const viewportDragStart = ref({ x: 0, y: 0 });
// Modal size state for resize functionality
// Modal size state - used by BaseModal
const modalSize = ref({ width: 800, height: 600 });
const isResizing = ref(false);
const initialSize = ref({ width: 0, height: 0 });
const resizeStart = ref({ x: 0, y: 0 });
const currentFrameDisplay = computed(() => {
if (sprites.value.length === 0) return '0 / 0';
@ -395,77 +372,7 @@
}
};
// Modal drag functionality
const startDrag = (e: MouseEvent) => {
// Don't allow drag if currently resizing
if (isResizing.value) return;
isDragging.value = true;
dragOffset.value = {
x: e.clientX - position.value.x,
y: e.clientY - position.value.y,
};
// Add temporary event listeners
window.addEventListener('mousemove', handleDrag);
window.addEventListener('mouseup', stopDrag);
};
const handleDrag = (e: MouseEvent) => {
if (!isDragging.value) return;
requestAnimationFrame(() => {
position.value = {
x: e.clientX - dragOffset.value.x,
y: e.clientY - dragOffset.value.y,
};
});
};
const stopDrag = () => {
isDragging.value = false;
window.removeEventListener('mousemove', handleDrag);
window.removeEventListener('mouseup', stopDrag);
};
// NEW: Modal resize functionality
const startResize = (e: MouseEvent) => {
isResizing.value = true;
initialSize.value = { ...modalSize.value };
resizeStart.value = { x: e.clientX, y: e.clientY };
// Add temporary event listeners
window.addEventListener('mousemove', handleResize);
window.addEventListener('mouseup', stopResize);
// Prevent default to avoid text selection
e.preventDefault();
};
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const deltaX = e.clientX - resizeStart.value.x;
const deltaY = e.clientY - resizeStart.value.y;
requestAnimationFrame(() => {
// Calculate new size with minimum constraints
const newWidth = Math.max(400, initialSize.value.width + deltaX);
const newHeight = Math.max(400, initialSize.value.height + deltaY);
// Update modal size
modalSize.value = {
width: newWidth,
height: newHeight,
};
});
};
const stopResize = () => {
isResizing.value = false;
window.removeEventListener('mousemove', handleResize);
window.removeEventListener('mouseup', stopResize);
};
// Modal drag and resize functionality is now handled by BaseModal
const initializeCanvas = async () => {
if (!animCanvas.value) {
@ -699,12 +606,8 @@
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('mousemove', handleDrag);
window.removeEventListener('mouseup', stopDrag);
window.removeEventListener('mousemove', handleCanvasDrag);
window.removeEventListener('mouseup', stopCanvasDrag);
window.removeEventListener('mousemove', handleResize);
window.removeEventListener('mouseup', stopResize);
window.removeEventListener('mousemove', handleViewportDrag);
window.removeEventListener('mouseup', stopViewportDrag);
});

View File

@ -1,20 +1,9 @@
<template>
<!-- Settings Modal -->
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none" v-if="isModalOpen">
<!-- Modal backdrop with semi-transparent background -->
<div class="absolute inset-0 bg-black bg-opacity-50 pointer-events-auto" @click="closeModal"></div>
<!-- Modal content -->
<div class="bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-auto shadow-lg pointer-events-auto relative">
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
<div class="flex items-center gap-2 text-lg font-semibold">
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true">
<template #header-icon>
<i class="fas fa-cog text-blue-500"></i>
<span>Settings</span>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-white">
<i class="fas fa-times"></i>
</button>
</div>
</template>
<template #header-title>Settings</template>
<div class="p-6">
<!-- Tools Section -->
@ -110,17 +99,22 @@
</div>
</div>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import BaseModal from './BaseModal.vue';
const store = useSpritesheetStore();
const sprites = computed(() => store.sprites.value);
const isModalOpen = computed(() => store.isSettingsModalOpen.value);
const isModalOpen = computed({
get: () => store.isSettingsModalOpen.value,
set: value => {
store.isSettingsModalOpen.value = value;
},
});
const previewBorder = computed(() => store.previewBorder);
// Column count control

View File

@ -1,20 +1,9 @@
<template>
<!-- Sprites Modal -->
<div class="fixed inset-0 flex items-center justify-center z-50 pointer-events-none" v-if="isModalOpen">
<!-- Modal backdrop with semi-transparent background -->
<div class="absolute inset-0 bg-black bg-opacity-50 pointer-events-auto" @click="closeModal"></div>
<!-- Modal content -->
<div class="bg-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-auto shadow-lg pointer-events-auto relative">
<div class="flex items-center justify-between p-4 bg-gray-700 border-b border-gray-600">
<div class="flex items-center gap-2 text-lg font-semibold">
<BaseModal v-model="isModalOpen" @close="closeModal" :movable="true" :resizable="true" :showBackdrop="false" :showBorder="true">
<template #header-icon>
<i class="fas fa-images text-blue-500"></i>
<span>Sprites</span>
</div>
<button @click="closeModal" class="text-gray-400 hover:text-white">
<i class="fas fa-times"></i>
</button>
</div>
</template>
<template #header-title>Sprites</template>
<div class="p-6">
<!-- Sprites List -->
@ -43,17 +32,22 @@
</div>
</div>
</div>
</div>
</div>
</BaseModal>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useSpritesheetStore } from '../composables/useSpritesheetStore';
import BaseModal from './BaseModal.vue';
const store = useSpritesheetStore();
const sprites = computed(() => store.sprites.value);
const isModalOpen = computed(() => store.isSpritesModalOpen.value);
const isModalOpen = computed({
get: () => store.isSpritesModalOpen.value,
set: value => {
store.isSpritesModalOpen.value = value;
},
});
const closeModal = () => {
store.isSpritesModalOpen.value = false;