593 lines
19 KiB
Vue
593 lines
19 KiB
Vue
<script setup>
|
|
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
|
|
import { jsPDF } from "jspdf";
|
|
import GoogleIcon from "@Shared/GoogleIcon.vue";
|
|
import Draggable from "@Holos/PDF/Draggable.vue";
|
|
import CanvasElement from "@Holos/PDF/Canvas.vue";
|
|
import PDFViewport from "@Holos/PDF/PDFViewport.vue";
|
|
import TextFormatter from "@Holos/PDF/TextFormatter.vue";
|
|
import TableEditorModal from "@Holos/PDF/TableEditorModal.vue";
|
|
import autoTable from "jspdf-autotable";
|
|
|
|
/** Estado Reactivo */
|
|
const pages = ref([{ id: 1, elements: [] }]);
|
|
const selectedElementId = ref(null);
|
|
const documentTitle = ref("Documento sin título");
|
|
const currentPage = ref(1);
|
|
const pageIdCounter = ref(1);
|
|
const isExporting = ref(false);
|
|
const currentPageSize = ref("A4");
|
|
const activeEditor = ref(null);
|
|
const isTableEditing = ref(false);
|
|
const isTableModalOpen = ref(false);
|
|
const editingTableId = ref(null);
|
|
|
|
const allElements = computed(() =>
|
|
pages.value.flatMap((page) => page.elements)
|
|
);
|
|
const totalElements = computed(() => allElements.value.length);
|
|
const selectedElement = computed(() =>
|
|
allElements.value.find((el) => el.id === selectedElementId.value)
|
|
);
|
|
|
|
const addPage = () => {
|
|
pages.value.push({ id: ++pageIdCounter.value, elements: [] });
|
|
};
|
|
|
|
const deletePage = (pageIndex) => {
|
|
if (pages.value.length > 1) {
|
|
if (currentPage.value > pageIndex + 1) {
|
|
currentPage.value--;
|
|
} else if (currentPage.value === pageIndex + 1 && currentPage.value > 1) {
|
|
currentPage.value--;
|
|
}
|
|
pages.value.splice(pageIndex, 1);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (dropData) => {
|
|
const data = JSON.parse(
|
|
dropData.originalEvent.dataTransfer.getData("text/plain")
|
|
);
|
|
const newElement = createNewElement({ ...data, ...dropData });
|
|
pages.value[dropData.pageIndex].elements.push(newElement);
|
|
selectElement(newElement.id);
|
|
};
|
|
|
|
const createNewElement = (data) => ({
|
|
id: `el-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
type: data.type,
|
|
pageIndex: data.pageIndex,
|
|
x: data.x,
|
|
y: data.y,
|
|
width: data.type === "table" ? 400 : 250,
|
|
height: data.type === "image" ? 150 : 120,
|
|
content: getDefaultContent(data.type),
|
|
});
|
|
|
|
const getDefaultContent = (type) => {
|
|
if (type === "text") return "<p>Escribe algo...</p>";
|
|
if (type === "table") {
|
|
return `<table class="tiptap-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Encabezado 1</th>
|
|
<th>Encabezado 2</th>
|
|
<th>Encabezado 3</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>Celda 1</td>
|
|
<td>Celda 2</td>
|
|
<td>Celda 3</td>
|
|
</tr>
|
|
<tr>
|
|
<td>Celda 4</td>
|
|
<td>Celda 5</td>
|
|
<td>Celda 6</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const selectElement = (elementId) => {
|
|
selectedElementId.value = elementId;
|
|
};
|
|
const deselectAll = (event) => {
|
|
if (event.target === event.currentTarget) {
|
|
selectedElementId.value = null;
|
|
activeEditor.value = null;
|
|
isTableEditing.value = false;
|
|
}
|
|
};
|
|
const moveElement = (moveData) => {
|
|
const el = allElements.value.find((e) => e.id === moveData.id);
|
|
if (el) Object.assign(el, moveData);
|
|
};
|
|
const updateElement = (update) => {
|
|
const el = allElements.value.find((e) => e.id === update.id);
|
|
if (el) Object.assign(el, update);
|
|
};
|
|
const deleteElement = (elementId) => {
|
|
pages.value.forEach((p) => {
|
|
p.elements = p.elements.filter((el) => el.id !== elementId);
|
|
});
|
|
if (selectedElementId.value === elementId) selectedElementId.value = null;
|
|
};
|
|
const clearCanvas = () => {
|
|
if (confirm("¿Deseas limpiar todo el documento?")) {
|
|
pages.value = [{ id: 1, elements: [] }];
|
|
selectedElementId.value = null;
|
|
pageIdCounter.value = 1;
|
|
currentPage.value = 1;
|
|
}
|
|
};
|
|
|
|
const handleTableEditing = (editing) => {
|
|
isTableEditing.value = editing;
|
|
};
|
|
|
|
const openTableEditor = (tableId) => {
|
|
editingTableId.value = tableId;
|
|
isTableModalOpen.value = true;
|
|
};
|
|
|
|
const saveTableContent = (newContent) => {
|
|
if (editingTableId.value) {
|
|
const el = allElements.value.find((e) => e.id === editingTableId.value);
|
|
if (el) {
|
|
el.content = newContent;
|
|
}
|
|
}
|
|
closeTableEditor();
|
|
};
|
|
|
|
const closeTableEditor = () => {
|
|
isTableModalOpen.value = false;
|
|
editingTableId.value = null;
|
|
};
|
|
|
|
const editingTable = computed(() => {
|
|
if (!editingTableId.value) return null;
|
|
return allElements.value.find((e) => e.id === editingTableId.value);
|
|
});
|
|
|
|
const handlePageSizeChange = (sizeData) => {
|
|
currentPageSize.value = sizeData.size;
|
|
};
|
|
|
|
const handleKeydown = (event) => {
|
|
if (event.key === "Delete" && selectedElementId.value)
|
|
deleteElement(selectedElementId.value);
|
|
if (event.key === "Escape") selectedElementId.value = null;
|
|
};
|
|
onMounted(() => document.addEventListener("keydown", handleKeydown));
|
|
onBeforeUnmount(() => document.removeEventListener("keydown", handleKeydown));
|
|
|
|
// Tamaños de página en mm
|
|
const PDF_SIZES = {
|
|
A4: { width: 210, height: 297, canvasWidth: 794, canvasHeight: 1123 },
|
|
A3: { width: 297, height: 420, canvasWidth: 1123, canvasHeight: 1587 },
|
|
Tabloid: { width: 279.4, height: 431.8, canvasWidth: 1056, canvasHeight: 1632 },
|
|
};
|
|
|
|
// Función para convertir color hex/rgb a array RGB
|
|
const parseColor = (colorStr) => {
|
|
if (!colorStr) return null;
|
|
|
|
// Si es hex (#RRGGBB)
|
|
if (colorStr.startsWith('#')) {
|
|
const hex = colorStr.replace('#', '');
|
|
return [
|
|
parseInt(hex.substr(0, 2), 16),
|
|
parseInt(hex.substr(2, 2), 16),
|
|
parseInt(hex.substr(4, 2), 16)
|
|
];
|
|
}
|
|
|
|
// Si es rgb(r, g, b)
|
|
const match = colorStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
|
|
if (match) {
|
|
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Función auxiliar para procesar texto HTML y extraer líneas con estilos
|
|
const parseHTMLContent = (html) => {
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = html;
|
|
|
|
const lines = [];
|
|
|
|
// Procesar cada párrafo
|
|
const paragraphs = temp.querySelectorAll('p');
|
|
|
|
if (paragraphs.length === 0) {
|
|
const text = temp.textContent.trim();
|
|
if (text) {
|
|
return [{
|
|
segments: [{ text, isBold: false, isItalic: false, color: null, fontSize: null }],
|
|
align: 'left',
|
|
isNewParagraph: false
|
|
}];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
paragraphs.forEach((p, pIndex) => {
|
|
// Detectar alineación del párrafo
|
|
let align = 'left';
|
|
if (p.style.textAlign) {
|
|
align = p.style.textAlign;
|
|
} else if (p.getAttribute('style')) {
|
|
const styleMatch = p.getAttribute('style').match(/text-align:\s*(left|center|right)/);
|
|
if (styleMatch) {
|
|
align = styleMatch[1];
|
|
}
|
|
}
|
|
|
|
// Recolectar segmentos de texto con estilos (inline, sin dividir por saltos)
|
|
const segments = [];
|
|
|
|
const processNode = (node, parentStyles = {}) => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent;
|
|
// Dividir por saltos de línea explícitos
|
|
const textLines = text.split('\n');
|
|
textLines.forEach((lineText, idx) => {
|
|
if (lineText.trim()) {
|
|
segments.push({
|
|
text: lineText,
|
|
isBold: parentStyles.isBold || false,
|
|
isItalic: parentStyles.isItalic || false,
|
|
color: parentStyles.color || null,
|
|
fontSize: parentStyles.fontSize || null,
|
|
forceBreak: idx > 0 // Si hay múltiples líneas, forzar salto después de cada una
|
|
});
|
|
}
|
|
});
|
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const element = node;
|
|
|
|
// Si es un <br>, forzar salto de línea
|
|
if (element.tagName === 'BR') {
|
|
if (segments.length > 0) {
|
|
segments[segments.length - 1].forceBreak = true;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const currentStyles = {
|
|
isBold: parentStyles.isBold || element.tagName === 'STRONG' || element.tagName === 'B',
|
|
isItalic: parentStyles.isItalic || element.tagName === 'EM' || element.tagName === 'I',
|
|
color: parseColor(element.style.color) || parentStyles.color,
|
|
fontSize: (element.style.fontSize ? parseInt(element.style.fontSize) : null) || parentStyles.fontSize
|
|
};
|
|
|
|
Array.from(element.childNodes).forEach(child => processNode(child, currentStyles));
|
|
}
|
|
};
|
|
|
|
Array.from(p.childNodes).forEach(node => processNode(node));
|
|
|
|
// Agrupar segmentos en líneas solo si hay salto forzado
|
|
let currentLine = { segments: [], align, isNewParagraph: false };
|
|
|
|
segments.forEach((segment, idx) => {
|
|
currentLine.segments.push({
|
|
text: segment.text,
|
|
isBold: segment.isBold,
|
|
isItalic: segment.isItalic,
|
|
color: segment.color,
|
|
fontSize: segment.fontSize
|
|
});
|
|
|
|
// Si hay salto forzado o es el último segmento, cerrar línea
|
|
if (segment.forceBreak || idx === segments.length - 1) {
|
|
if (currentLine.segments.length > 0) {
|
|
lines.push(currentLine);
|
|
currentLine = { segments: [], align, isNewParagraph: false };
|
|
}
|
|
}
|
|
});
|
|
|
|
// Marcar nuevo párrafo
|
|
if (pIndex < paragraphs.length - 1 && lines.length > 0) {
|
|
lines[lines.length - 1].isNewParagraph = true;
|
|
}
|
|
});
|
|
|
|
return lines;
|
|
};
|
|
|
|
const exportPDF = () => {
|
|
isExporting.value = true;
|
|
try {
|
|
const pageConfig = PDF_SIZES[currentPageSize.value] || PDF_SIZES.A4;
|
|
|
|
// Crear documento PDF
|
|
const pdf = new jsPDF({
|
|
orientation: 'portrait',
|
|
unit: 'mm',
|
|
format: [pageConfig.width, pageConfig.height]
|
|
});
|
|
|
|
// Factor de escala de canvas a PDF (mm)
|
|
const scaleX = pageConfig.width / pageConfig.canvasWidth;
|
|
const scaleY = pageConfig.height / pageConfig.canvasHeight;
|
|
|
|
pages.value.forEach((page, pageIndex) => {
|
|
if (pageIndex > 0) {
|
|
pdf.addPage([pageConfig.width, pageConfig.height]);
|
|
}
|
|
|
|
page.elements.forEach((element) => {
|
|
const x = element.x * scaleX;
|
|
const y = element.y * scaleY;
|
|
const width = element.width * scaleX;
|
|
const height = element.height * scaleY;
|
|
|
|
if (element.type === 'text' && element.content) {
|
|
const lines = parseHTMLContent(element.content);
|
|
let currentY = y + 5;
|
|
const lineHeight = 4.5;
|
|
|
|
lines.forEach((line) => {
|
|
// Construir el texto completo de la línea combinando segmentos
|
|
let fullLineText = '';
|
|
let currentX = x + 1;
|
|
|
|
// Calcular posición inicial según alineación
|
|
if (line.align === 'center') {
|
|
currentX = x + (width / 2);
|
|
} else if (line.align === 'right') {
|
|
currentX = x + width - 1;
|
|
}
|
|
|
|
// Si hay múltiples segmentos con diferentes estilos, dibujar cada uno
|
|
if (line.segments.length === 1 && !line.segments[0].color && !line.segments[0].isBold && !line.segments[0].isItalic) {
|
|
// Caso simple: un solo segmento sin estilos especiales
|
|
const segment = line.segments[0];
|
|
pdf.setFontSize(segment.fontSize || 12);
|
|
pdf.setFont('helvetica', 'normal');
|
|
pdf.setTextColor(0, 0, 0);
|
|
|
|
const textLines = pdf.splitTextToSize(segment.text, width - 2);
|
|
textLines.forEach((txtLine) => {
|
|
pdf.text(txtLine, currentX, currentY, { align: line.align });
|
|
currentY += lineHeight;
|
|
});
|
|
} else {
|
|
// Caso complejo: múltiples segmentos o con estilos
|
|
// Primero, calcular el ancho total de la línea
|
|
let totalWidth = 0;
|
|
const segmentWidths = [];
|
|
|
|
line.segments.forEach((segment) => {
|
|
pdf.setFontSize(segment.fontSize || 12);
|
|
let fontStyle = 'normal';
|
|
if (segment.isBold && segment.isItalic) fontStyle = 'bolditalic';
|
|
else if (segment.isBold) fontStyle = 'bold';
|
|
else if (segment.isItalic) fontStyle = 'italic';
|
|
pdf.setFont('helvetica', fontStyle);
|
|
|
|
const segWidth = pdf.getTextWidth(segment.text);
|
|
segmentWidths.push(segWidth);
|
|
totalWidth += segWidth;
|
|
});
|
|
|
|
// Ajustar posición inicial según alineación
|
|
let startX = x + 1;
|
|
if (line.align === 'center') {
|
|
startX = x + (width / 2) - (totalWidth / 2);
|
|
} else if (line.align === 'right') {
|
|
startX = x + width - 1 - totalWidth;
|
|
}
|
|
|
|
let segmentX = startX;
|
|
|
|
// Dibujar cada segmento
|
|
line.segments.forEach((segment, segIdx) => {
|
|
pdf.setFontSize(segment.fontSize || 12);
|
|
|
|
let fontStyle = 'normal';
|
|
if (segment.isBold && segment.isItalic) fontStyle = 'bolditalic';
|
|
else if (segment.isBold) fontStyle = 'bold';
|
|
else if (segment.isItalic) fontStyle = 'italic';
|
|
pdf.setFont('helvetica', fontStyle);
|
|
|
|
if (segment.color) {
|
|
pdf.setTextColor(segment.color[0], segment.color[1], segment.color[2]);
|
|
} else {
|
|
pdf.setTextColor(0, 0, 0);
|
|
}
|
|
|
|
pdf.text(segment.text, segmentX, currentY, { align: 'left' });
|
|
segmentX += segmentWidths[segIdx];
|
|
});
|
|
|
|
currentY += lineHeight;
|
|
}
|
|
|
|
// Agregar espacio extra si es nuevo párrafo
|
|
if (line.isNewParagraph) {
|
|
currentY += 1.5;
|
|
}
|
|
});
|
|
|
|
// Resetear color y fuente
|
|
pdf.setTextColor(0, 0, 0);
|
|
pdf.setFont('helvetica', 'normal');
|
|
}
|
|
|
|
if (element.type === 'image' && element.content) {
|
|
try {
|
|
pdf.addImage(element.content, 'PNG', x, y, width, height);
|
|
} catch (e) {
|
|
console.warn('Error al agregar imagen:', e);
|
|
}
|
|
}
|
|
|
|
if (element.type === 'table' && element.content) {
|
|
// Parsear tabla HTML de Tiptap
|
|
const temp = document.createElement('div');
|
|
temp.innerHTML = element.content;
|
|
const table = temp.querySelector('table');
|
|
|
|
if (table) {
|
|
// Extraer headers
|
|
const headerCells = Array.from(table.querySelectorAll('thead th, thead td'));
|
|
const head = headerCells.length > 0
|
|
? [headerCells.map(th => th.textContent.trim())]
|
|
: null;
|
|
|
|
// Extraer filas del body
|
|
const bodyRows = Array.from(table.querySelectorAll('tbody tr'));
|
|
const body = bodyRows.map(row => {
|
|
const cells = Array.from(row.children);
|
|
return cells.map(cell => cell.textContent.trim());
|
|
});
|
|
|
|
// Usar autoTable para renderizar la tabla
|
|
autoTable(pdf, {
|
|
head: head,
|
|
body: body,
|
|
startY: y,
|
|
margin: { left: x },
|
|
tableWidth: width,
|
|
styles: {
|
|
fontSize: 10,
|
|
cellPadding: 2,
|
|
lineColor: [209, 213, 219],
|
|
lineWidth: 0.1,
|
|
},
|
|
headStyles: {
|
|
fillColor: [249, 250, 251],
|
|
textColor: [0, 0, 0],
|
|
fontStyle: 'bold',
|
|
},
|
|
bodyStyles: {
|
|
textColor: [0, 0, 0],
|
|
},
|
|
theme: 'grid',
|
|
tableLineColor: [209, 213, 219],
|
|
tableLineWidth: 0.1,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
pdf.save(`${documentTitle.value || "documento"}.pdf`);
|
|
} catch (e) {
|
|
console.error("Error al exportar PDF:", e);
|
|
alert("Hubo un error al generar el PDF.");
|
|
} finally {
|
|
isExporting.value = false;
|
|
}
|
|
};
|
|
|
|
const availableElements = [
|
|
{ type: "text", icon: "text_fields", title: "Texto" },
|
|
{ type: "image", icon: "image", title: "Imagen" },
|
|
{ type: "table", icon: "table_chart", title: "Tabla" },
|
|
];
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex flex-col lg:flex-row h-screen bg-gray-50">
|
|
<div class="w-full lg:w-64 bg-white border-r border-gray-200 flex flex-col">
|
|
<div class="p-3 lg:p-4 border-b">
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<GoogleIcon name="text_snippet" class="text-blue-600 text-lg" />
|
|
<h2 class="font-semibold text-gray-900 text-sm">Diseñador</h2>
|
|
</div>
|
|
<input
|
|
v-model="documentTitle"
|
|
class="w-full px-2 py-1 text-xs border rounded mb-3"
|
|
placeholder="Título del documento"
|
|
/>
|
|
<button
|
|
@click="exportPDF"
|
|
:disabled="isExporting"
|
|
class="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm rounded transition-colors bg-red-600 hover:bg-red-700 text-white"
|
|
>
|
|
<GoogleIcon name="picture_as_pdf" class="text-sm" />{{
|
|
isExporting ? "Generando..." : "Exportar PDF"
|
|
}}
|
|
</button>
|
|
</div>
|
|
<div class="p-3 lg:p-4 flex-1 overflow-y-auto">
|
|
<h3
|
|
class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3"
|
|
>
|
|
Elementos
|
|
</h3>
|
|
<div class="grid grid-cols-1 gap-2">
|
|
<Draggable
|
|
v-for="el in availableElements"
|
|
:key="el.type"
|
|
v-bind="el"
|
|
/>
|
|
</div>
|
|
<div class="pt-4 mt-4 border-t">
|
|
<button
|
|
v-if="totalElements > 0"
|
|
@click="clearCanvas"
|
|
class="w-full flex items-center justify-center gap-1 px-2 py-1 text-xs text-red-600 hover:bg-red-50 rounded"
|
|
>
|
|
<GoogleIcon name="delete" class="text-sm" />Limpiar Todo
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 flex flex-col min-h-0">
|
|
<TextFormatter
|
|
:editor="activeEditor"
|
|
:selected-element="selectedElement"
|
|
:is-table-editing="isTableEditing"
|
|
/>
|
|
<PDFViewport
|
|
:pages="pages"
|
|
:current-page="currentPage"
|
|
@page-change="(p) => (currentPage = p)"
|
|
@add-page="addPage"
|
|
@delete-page="deletePage"
|
|
@drop="handleDrop"
|
|
@click="deselectAll"
|
|
@page-size-change="handlePageSizeChange"
|
|
>
|
|
<template #elements="{ page, dimensions }">
|
|
<CanvasElement
|
|
v-for="element in page.elements"
|
|
:key="element.id"
|
|
:element="element"
|
|
:is-selected="selectedElementId === element.id"
|
|
:page-dimensions="dimensions"
|
|
@select="selectElement"
|
|
@delete="deleteElement"
|
|
@update="updateElement"
|
|
@move="moveElement"
|
|
@editor-active="(editor) => (activeEditor = editor)"
|
|
@table-editing="handleTableEditing"
|
|
@edit-table="openTableEditor"
|
|
/>
|
|
</template>
|
|
</PDFViewport>
|
|
</div>
|
|
|
|
<!-- Modal de edición de tabla -->
|
|
<TableEditorModal
|
|
v-if="isTableModalOpen && editingTable"
|
|
:model-value="editingTable.content"
|
|
@save="saveTableContent"
|
|
@cancel="closeTableEditor"
|
|
/>
|
|
</div>
|
|
</template>
|