2025-09-23 13:50:25 -06:00

353 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>