353 lines
14 KiB
Vue
353 lines
14 KiB
Vue
<script setup>
|
||
import { ref, computed, nextTick, watch } from 'vue';
|
||
import GoogleIcon from '@Shared/GoogleIcon.vue';
|
||
import PageSizeSelector from '@Holos/PDF/PageSizeSelector.vue';
|
||
|
||
/** Propiedades */
|
||
const props = defineProps({
|
||
pages: {
|
||
type: Array,
|
||
default: () => [{ id: 1, elements: [] }]
|
||
},
|
||
selectedElementId: String,
|
||
isExporting: Boolean
|
||
});
|
||
|
||
/** Eventos */
|
||
const emit = defineEmits(['drop', 'dragover', 'click', 'add-page', 'delete-page', 'page-change', 'page-size-change']);
|
||
|
||
/** Referencias */
|
||
const viewportRef = ref(null);
|
||
const currentPage = ref(1);
|
||
const pageSize = ref('A4');
|
||
|
||
/** Tamaños de página */
|
||
const pageSizes = {
|
||
'A4': { width: 794, height: 1123, label: '210 × 297 mm' },
|
||
'A3': { width: 1123, height: 1587, label: '297 × 420 mm' },
|
||
'Letter': { width: 816, height: 1056, label: '216 × 279 mm' },
|
||
'Legal': { width: 816, height: 1344, label: '216 × 356 mm' },
|
||
'Tabloid': { width: 1056, height: 1632, label: '279 × 432 mm' }
|
||
};
|
||
|
||
/** Constantes de diseño ajustadas */
|
||
const PAGE_MARGIN = 50;
|
||
const ZOOM_LEVEL = 0.65;
|
||
|
||
/** Propiedades computadas */
|
||
const currentPageSize = computed(() => pageSizes[pageSize.value]);
|
||
const PAGE_WIDTH = computed(() => currentPageSize.value.width);
|
||
const PAGE_HEIGHT = computed(() => currentPageSize.value.height);
|
||
const scaledPageWidth = computed(() => PAGE_WIDTH.value * ZOOM_LEVEL);
|
||
const scaledPageHeight = computed(() => PAGE_HEIGHT.value * ZOOM_LEVEL);
|
||
const totalPages = computed(() => props.pages.length);
|
||
|
||
/** Watchers */
|
||
watch(pageSize, (newSize) => {
|
||
emit('page-size-change', {
|
||
size: newSize,
|
||
dimensions: pageSizes[newSize]
|
||
});
|
||
});
|
||
|
||
/** Métodos */
|
||
const handleDrop = (event, pageIndex) => {
|
||
event.preventDefault();
|
||
|
||
const pageElement = event.currentTarget;
|
||
const rect = pageElement.getBoundingClientRect();
|
||
|
||
const relativeX = (event.clientX - rect.left) / ZOOM_LEVEL;
|
||
const relativeY = (event.clientY - rect.top) / ZOOM_LEVEL;
|
||
|
||
emit('drop', {
|
||
originalEvent: event,
|
||
pageIndex,
|
||
x: Math.max(0, Math.min(PAGE_WIDTH.value, relativeX)),
|
||
y: Math.max(0, Math.min(PAGE_HEIGHT.value, relativeY))
|
||
});
|
||
};
|
||
|
||
const handleDragOver = (event) => {
|
||
event.preventDefault();
|
||
emit('dragover', event);
|
||
};
|
||
|
||
const handleClick = (event, pageIndex) => {
|
||
if (event.target.classList.contains('pdf-page')) {
|
||
emit('click', { originalEvent: event, pageIndex });
|
||
}
|
||
};
|
||
|
||
const handleNextPage = () => {
|
||
if (currentPage.value >= totalPages.value) {
|
||
addPage();
|
||
} else {
|
||
setCurrentPage(currentPage.value + 1);
|
||
}
|
||
};
|
||
|
||
const addPage = () => {
|
||
emit('add-page');
|
||
// Solo cambiar a la nueva página cuando se agrega una
|
||
nextTick(() => {
|
||
const newPageNumber = totalPages.value + 1;
|
||
setCurrentPage(newPageNumber);
|
||
});
|
||
};
|
||
|
||
const deletePage = (pageIndex) => {
|
||
if (totalPages.value > 1) {
|
||
emit('delete-page', pageIndex);
|
||
}
|
||
};
|
||
|
||
const scrollToPage = (pageNumber) => {
|
||
if (viewportRef.value) {
|
||
const pageElement = viewportRef.value.querySelector(`[data-page="${pageNumber}"]`);
|
||
if (pageElement) {
|
||
pageElement.scrollIntoView({
|
||
behavior: 'smooth',
|
||
block: 'nearest',
|
||
inline: 'center'
|
||
});
|
||
}
|
||
}
|
||
};
|
||
|
||
const setCurrentPage = (pageNumber) => {
|
||
currentPage.value = pageNumber;
|
||
emit('page-change', pageNumber);
|
||
|
||
// Mantener la página actual centrada
|
||
nextTick(() => {
|
||
scrollToPage(pageNumber);
|
||
});
|
||
};
|
||
|
||
/** Métodos expuestos */
|
||
defineExpose({
|
||
scrollToPage,
|
||
setCurrentPage,
|
||
PAGE_WIDTH,
|
||
PAGE_HEIGHT,
|
||
ZOOM_LEVEL
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex-1 flex flex-col bg-gray-100 dark:bg-primary-d/20">
|
||
<!-- Toolbar de páginas -->
|
||
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-primary-d border-b border-gray-200 dark:border-primary/20">
|
||
<div class="flex items-center gap-4">
|
||
<span class="text-sm font-medium text-gray-700 dark:text-primary-dt">
|
||
Página {{ currentPage }} de {{ totalPages }}
|
||
</span>
|
||
|
||
<div class="flex items-center gap-1 border-l border-gray-200 dark:border-primary/20 pl-4">
|
||
<button
|
||
@click="setCurrentPage(Math.max(1, currentPage - 1))"
|
||
:disabled="currentPage <= 1"
|
||
class="p-1.5 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt rounded hover:bg-gray-100 dark:hover:bg-primary/10"
|
||
title="Página anterior"
|
||
>
|
||
<GoogleIcon name="keyboard_arrow_left" class="text-lg" />
|
||
</button>
|
||
|
||
<button
|
||
@click="handleNextPage"
|
||
:disabled="isExporting"
|
||
class="p-1.5 text-gray-400 hover:text-gray-600 disabled:opacity-50 disabled:cursor-not-allowed dark:text-primary-dt/70 dark:hover:text-primary-dt rounded hover:bg-gray-100 dark:hover:bg-primary/10 relative"
|
||
:title="currentPage >= totalPages ? 'Crear nueva página' : 'Página siguiente'"
|
||
>
|
||
<GoogleIcon name="keyboard_arrow_right" class="text-lg" />
|
||
<!-- Indicador solo cuando estamos en la última página -->
|
||
<GoogleIcon
|
||
v-if="currentPage >= totalPages"
|
||
name="add"
|
||
class="absolute -top-1 -right-1 text-xs text-green-500 bg-white rounded-full"
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-4">
|
||
<!-- Selector de tamaño de página -->
|
||
<PageSizeSelector v-model="pageSize" />
|
||
|
||
<span class="text-xs text-gray-500 dark:text-primary-dt/70 bg-gray-50 dark:bg-primary/10 px-2 py-1 rounded">
|
||
{{ Math.round(ZOOM_LEVEL * 100) }}% • {{ currentPageSize.label }}
|
||
</span>
|
||
|
||
<button
|
||
@click="addPage"
|
||
:disabled="isExporting"
|
||
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 font-medium"
|
||
>
|
||
<GoogleIcon name="add" class="text-sm" />
|
||
Nueva Página
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Viewport de páginas horizontal -->
|
||
<div
|
||
ref="viewportRef"
|
||
class="flex-1 overflow-auto"
|
||
style="background-color: #f8fafc; background-image: radial-gradient(circle, #e2e8f0 1px, transparent 1px); background-size: 24px 24px;"
|
||
>
|
||
<!-- Contenedor horizontal centrado -->
|
||
<div class="flex items-center justify-center min-h-full p-6">
|
||
<div class="flex items-center gap-8">
|
||
<!-- Páginas -->
|
||
<div
|
||
v-for="(page, pageIndex) in pages"
|
||
:key="page.id"
|
||
:data-page="pageIndex + 1"
|
||
class="relative group flex-shrink-0"
|
||
>
|
||
<!-- Header de página -->
|
||
<div class="flex flex-col items-center mb-3">
|
||
<div class="flex items-center gap-3">
|
||
<span class="text-sm font-medium text-gray-600 dark:text-primary-dt/80">
|
||
Página {{ pageIndex + 1 }}
|
||
</span>
|
||
<button
|
||
v-if="totalPages > 1"
|
||
@click="deletePage(pageIndex)"
|
||
:disabled="isExporting"
|
||
class="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 disabled:opacity-50 p-1 rounded hover:bg-red-50 transition-all"
|
||
title="Eliminar página"
|
||
>
|
||
<GoogleIcon name="delete" class="text-sm" />
|
||
</button>
|
||
</div>
|
||
<span class="text-xs text-gray-400 dark:text-primary-dt/50">
|
||
{{ currentPageSize.label }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Contenedor de página con sombra -->
|
||
<div class="relative">
|
||
<!-- Sombra de página -->
|
||
<div class="absolute top-2 left-2 w-full h-full bg-gray-400/30 rounded-lg"></div>
|
||
|
||
<!-- Página PDF -->
|
||
<div
|
||
class="pdf-page relative bg-white rounded-lg border border-gray-300 dark:border-primary/20 overflow-hidden"
|
||
:class="{
|
||
'ring-2 ring-blue-500 ring-opacity-50 shadow-lg': currentPage === pageIndex + 1,
|
||
'shadow-md hover:shadow-lg': currentPage !== pageIndex + 1,
|
||
'opacity-50': isExporting
|
||
}"
|
||
:style="{
|
||
width: `${scaledPageWidth}px`,
|
||
height: `${scaledPageHeight}px`
|
||
}"
|
||
@drop="(e) => handleDrop(e, pageIndex)"
|
||
@dragover="handleDragOver"
|
||
@click="(e) => handleClick(e, pageIndex)"
|
||
>
|
||
<!-- Área de contenido con márgenes visuales -->
|
||
<div class="relative w-full h-full">
|
||
<!-- Guías de margen -->
|
||
<div
|
||
class="absolute border border-dashed border-blue-300/40 pointer-events-none"
|
||
:style="{
|
||
top: `${PAGE_MARGIN * ZOOM_LEVEL}px`,
|
||
left: `${PAGE_MARGIN * ZOOM_LEVEL}px`,
|
||
width: `${(PAGE_WIDTH - (PAGE_MARGIN * 2)) * ZOOM_LEVEL}px`,
|
||
height: `${(PAGE_HEIGHT - (PAGE_MARGIN * 2)) * ZOOM_LEVEL}px`
|
||
}"
|
||
></div>
|
||
|
||
<!-- Elementos de la página con transformación -->
|
||
<div
|
||
class="absolute inset-0"
|
||
:style="{
|
||
transform: `scale(${ZOOM_LEVEL})`,
|
||
transformOrigin: 'top left',
|
||
width: `${PAGE_WIDTH}px`,
|
||
height: `${PAGE_HEIGHT}px`
|
||
}"
|
||
>
|
||
<slot
|
||
name="elements"
|
||
:page="page"
|
||
:pageIndex="pageIndex"
|
||
:pageWidth="PAGE_WIDTH"
|
||
:pageHeight="PAGE_HEIGHT"
|
||
:zoomLevel="ZOOM_LEVEL"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Indicador de página vacía -->
|
||
<div
|
||
v-if="page.elements.length === 0"
|
||
class="absolute inset-0 flex items-center justify-center pointer-events-none z-10"
|
||
:style="{ transform: `scale(${1/ZOOM_LEVEL})` }"
|
||
>
|
||
<div class="text-center text-gray-400 dark:text-primary-dt/50">
|
||
<GoogleIcon name="description" class="text-4xl mb-2" />
|
||
<p class="text-sm font-medium">Página {{ pageIndex + 1 }}</p>
|
||
<p class="text-xs">Arrastra elementos aquí</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Overlay durante exportación -->
|
||
<div
|
||
v-if="isExporting"
|
||
class="absolute inset-0 bg-white/90 dark:bg-primary-d/90 flex items-center justify-center z-50 backdrop-blur-sm"
|
||
>
|
||
<div class="text-center bg-white dark:bg-primary-d rounded-lg p-6 shadow-lg border border-gray-200 dark:border-primary/20">
|
||
<GoogleIcon name="picture_as_pdf" class="text-5xl text-red-600 dark:text-red-400 animate-pulse mb-3" />
|
||
<p class="text-lg font-semibold text-gray-900 dark:text-primary-dt mb-1">Generando PDF...</p>
|
||
<p class="text-sm text-gray-500 dark:text-primary-dt/70">Procesando {{ totalPages }} página{{ totalPages !== 1 ? 's' : '' }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.pdf-page {
|
||
transition: all 0.3s ease;
|
||
position: relative;
|
||
}
|
||
|
||
.pdf-page:hover {
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.pdf-page.ring-2 {
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
.overflow-auto {
|
||
scroll-behavior: smooth;
|
||
}
|
||
|
||
.overflow-auto::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.overflow-auto::-webkit-scrollbar-thumb {
|
||
background: #cbd5e1;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.overflow-auto::-webkit-scrollbar-thumb:hover {
|
||
background: #94a3b8;
|
||
}
|
||
|
||
.overflow-auto::-webkit-scrollbar {
|
||
height: 8px;
|
||
width: 8px;
|
||
}
|
||
</style> |