Juan Felipe Zapata Moreno 1ce1fb30fc FIX: Tabla WIP
2025-09-30 16:22:56 -06:00

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>