1619 lines
61 KiB
HTML
1619 lines
61 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="es">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Estación de Documentos Premium</title>
|
|
|
|
<!-- Fonts -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
|
|
rel="stylesheet">
|
|
<!-- Icons -->
|
|
<link href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css" rel="stylesheet">
|
|
|
|
<!-- Libraries -->
|
|
<script src="https://unpkg.com/turndown/dist/turndown.js"></script>
|
|
<script src="https://unpkg.com/turndown-plugin-gfm/dist/turndown-plugin-gfm.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/mammoth.browser.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js"></script>
|
|
<style>
|
|
:root {
|
|
/* Light Theme */
|
|
--bg-body: #f1f5f9;
|
|
--bg-card: #ffffff;
|
|
--text-primary: #1e293b;
|
|
--text-secondary: #64748b;
|
|
--accent-primary: #3b82f6;
|
|
--accent-hover: #2563eb;
|
|
--accent-secondary: #6366f1;
|
|
--border-color: #e2e8f0;
|
|
--toolbar-bg: #f8fafc;
|
|
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg-body: #0f172a;
|
|
--bg-card: #1e293b;
|
|
--text-primary: #f1f5f9;
|
|
--text-secondary: #94a3b8;
|
|
--accent-primary: #60a5fa;
|
|
--accent-hover: #3b82f6;
|
|
--border-color: #334155;
|
|
--toolbar-bg: #1e293b;
|
|
}
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Outfit', sans-serif;
|
|
background-color: var(--bg-body);
|
|
color: var(--text-primary);
|
|
line-height: 1.6;
|
|
padding: 2rem;
|
|
height: 100vh;
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
header {
|
|
margin-bottom: 1.5rem;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
h1 {
|
|
font-size: 1.5rem;
|
|
font-weight: 700;
|
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
margin: 0;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.btn-main {
|
|
background: var(--bg-card);
|
|
border: 1px solid var(--border-color);
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 8px;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.btn-main:hover {
|
|
box-shadow: var(--shadow-sm);
|
|
border-color: var(--accent-primary);
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.btn-main.primary {
|
|
background: var(--accent-primary);
|
|
color: white;
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.btn-main.primary:hover {
|
|
background: var(--accent-hover);
|
|
}
|
|
|
|
.main-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 2rem;
|
|
flex: 1;
|
|
min-height: 0;
|
|
height: calc(100vh - 150px);
|
|
}
|
|
|
|
/* CARD STYLES */
|
|
.card {
|
|
background: var(--bg-card);
|
|
border-radius: 16px;
|
|
box-shadow: var(--shadow-md);
|
|
border: 1px solid var(--border-color);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
position: relative;
|
|
/* For drop overlay */
|
|
}
|
|
|
|
.card-header {
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: var(--toolbar-bg);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.card-title {
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.card-subtitle {
|
|
font-size: 0.8rem;
|
|
color: var(--success);
|
|
background: rgba(16, 185, 129, 0.1);
|
|
padding: 0.2rem 0.5rem;
|
|
border-radius: 4px;
|
|
opacity: 1;
|
|
/* Always visible for status */
|
|
transition: color 0.3s;
|
|
}
|
|
|
|
/* EDITOR SIDE */
|
|
.editor-container {
|
|
position: relative;
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 0;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
background: var(--bg-card);
|
|
overflow-x: auto;
|
|
}
|
|
|
|
.btn-tool {
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
color: var(--text-secondary);
|
|
padding: 0.4rem 0.6rem;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.btn-tool:hover {
|
|
background: rgba(59, 130, 246, 0.1);
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
textarea {
|
|
flex: 1;
|
|
width: 100%;
|
|
padding: 1.5rem;
|
|
border: none;
|
|
background: var(--bg-card);
|
|
color: var(--text-primary);
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 1rem;
|
|
resize: none;
|
|
outline: none;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
/* DROP ZONE OVERLAY */
|
|
.drop-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(59, 130, 246, 0.05);
|
|
/* Very light blue */
|
|
border: 2px dashed var(--accent-primary);
|
|
backdrop-filter: blur(2px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
z-index: 10;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
/* Make it pass-through when not active */
|
|
transition: opacity 0.2s;
|
|
}
|
|
|
|
.drop-active .drop-overlay {
|
|
opacity: 1;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.drop-message {
|
|
background: var(--bg-card);
|
|
padding: 2rem;
|
|
border-radius: 12px;
|
|
box-shadow: var(--shadow-lg);
|
|
text-align: center;
|
|
color: var(--accent-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* PREVIEW SIDE */
|
|
.preview-container {
|
|
flex: 1;
|
|
padding: 2rem;
|
|
/* Reduced padding */
|
|
overflow-y: auto;
|
|
background: var(--bg-card);
|
|
}
|
|
|
|
/* Make preview editable for bi-directional sync */
|
|
.preview-content {
|
|
outline: none;
|
|
min-height: 100%;
|
|
}
|
|
|
|
/* MARKDOWN STYLES */
|
|
.markdown-body h1 {
|
|
font-size: 1.75rem;
|
|
font-weight: 700;
|
|
margin-bottom: 1.5rem;
|
|
padding-bottom: 0.5rem;
|
|
border-bottom: 2px solid var(--border-color);
|
|
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
}
|
|
|
|
.markdown-body h2 {
|
|
font-size: 1.4rem;
|
|
margin-top: 2rem;
|
|
margin-bottom: 1rem;
|
|
color: var(--text-primary);
|
|
border-left: 4px solid var(--accent-primary);
|
|
padding-left: 1rem;
|
|
}
|
|
|
|
.markdown-body h3 {
|
|
font-size: 1.15rem;
|
|
margin-top: 1.5rem;
|
|
color: var(--text-secondary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.markdown-body p,
|
|
.markdown-body li {
|
|
color: var(--text-secondary);
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.markdown-body ul,
|
|
.markdown-body ol {
|
|
margin-left: 1.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.markdown-body strong {
|
|
color: var(--text-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Tables */
|
|
.table-wrapper {
|
|
overflow-x: auto;
|
|
margin: 1.5rem 0;
|
|
border-radius: 8px;
|
|
box-shadow: var(--shadow-sm);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.markdown-body table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.markdown-body th {
|
|
background-color: var(--bg-body);
|
|
text-align: left;
|
|
padding: 1rem;
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
border-bottom: 2px solid var(--border-color);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.markdown-body td {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.markdown-body tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.markdown-body tr:hover {
|
|
background-color: rgba(59, 130, 246, 0.05);
|
|
}
|
|
|
|
/* Code */
|
|
.markdown-body pre {
|
|
background: #1e293b;
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
overflow-x: auto;
|
|
color: #f8fafc;
|
|
margin: 1.5rem 0;
|
|
}
|
|
|
|
.markdown-body code {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-size: 0.85em;
|
|
}
|
|
|
|
.markdown-body p code {
|
|
background: rgba(99, 102, 241, 0.1);
|
|
color: var(--accent-secondary);
|
|
padding: 0.2em 0.4em;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
/* Progress Bar */
|
|
.progress-container {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 4px;
|
|
background: transparent;
|
|
z-index: 20;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
|
|
width: 0%;
|
|
transition: width 0.3s ease;
|
|
}
|
|
|
|
.status-pill {
|
|
position: absolute;
|
|
bottom: 2rem;
|
|
right: 2rem;
|
|
background: var(--text-primary);
|
|
color: var(--bg-body);
|
|
padding: 0.75rem 1.25rem;
|
|
border-radius: 50px;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
box-shadow: var(--shadow-lg);
|
|
display: none;
|
|
z-index: 1000;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateY(20px);
|
|
opacity: 0;
|
|
}
|
|
|
|
to {
|
|
transform: translateY(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
/* MODAL */
|
|
dialog {
|
|
margin: auto;
|
|
padding: 0;
|
|
border: none;
|
|
border-radius: 16px;
|
|
box-shadow: var(--shadow-lg);
|
|
background: var(--bg-card);
|
|
color: var(--text-primary);
|
|
max-width: 500px;
|
|
width: 90%;
|
|
}
|
|
|
|
dialog::backdrop {
|
|
background: rgba(0, 0, 0, 0.5);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 2rem;
|
|
}
|
|
|
|
.modal-header {
|
|
font-size: 1.25rem;
|
|
font-weight: 600;
|
|
margin-bottom: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.modal-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 1rem;
|
|
margin-top: 1.5rem;
|
|
}
|
|
|
|
.form-row {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.form-row label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-size: 0.9rem;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.form-radio {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
input[type="text"] {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 1px solid var(--border-color);
|
|
background: var(--bg-body);
|
|
color: var(--text-primary);
|
|
border-radius: 8px;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
/* TABS STYLES */
|
|
.tabs-container {
|
|
display: flex;
|
|
background: var(--toolbar-bg);
|
|
border-bottom: 1px solid var(--border-color);
|
|
overflow-x: auto;
|
|
padding: 0 1rem;
|
|
gap: 0.5rem;
|
|
min-height: 40px;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.tab {
|
|
padding: 0.5rem 1rem;
|
|
background: rgba(0,0,0,0.05);
|
|
border: 1px solid var(--border-color);
|
|
border-bottom: none;
|
|
border-radius: 8px 8px 0 0;
|
|
font-size: 0.85rem;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
transition: all 0.2s;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.tab span {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
.tab:hover {
|
|
background: var(--bg-card);
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.tab.active {
|
|
background: var(--bg-card);
|
|
color: var(--accent-primary);
|
|
border-bottom: 2px solid var(--accent-primary);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.tab-close {
|
|
font-size: 1.1rem;
|
|
opacity: 0.5;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.tab-close:hover {
|
|
opacity: 1;
|
|
color: var(--danger);
|
|
}
|
|
|
|
/* FIND & REPLACE STYLES */
|
|
.find-replace-bar {
|
|
background: var(--toolbar-bg);
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 0.5rem 1rem;
|
|
display: none;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
animation: slideDown 0.2s ease;
|
|
}
|
|
|
|
@keyframes slideDown {
|
|
from { transform: translateY(-10px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
|
|
.find-replace-bar input {
|
|
padding: 0.4rem 0.6rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
background: var(--bg-card);
|
|
color: var(--text-primary);
|
|
width: 150px;
|
|
}
|
|
|
|
/* BREATHING DOT STYLES */
|
|
.sync-indicator {
|
|
position: absolute;
|
|
left: 5px;
|
|
width: 8px;
|
|
height: 8px;
|
|
background: var(--accent-primary);
|
|
border-radius: 50%;
|
|
pointer-events: none;
|
|
opacity: 0.4;
|
|
animation: breathe 2s infinite ease-in-out;
|
|
z-index: 5;
|
|
display: none;
|
|
}
|
|
|
|
@keyframes breathe {
|
|
0%, 100% { transform: scale(0.8); opacity: 0.2; }
|
|
50% { transform: scale(1.2); opacity: 0.6; }
|
|
}
|
|
</style>
|
|
<style media="print">
|
|
body * { visibility: hidden; }
|
|
#preview, #preview * { visibility: visible; }
|
|
#preview {
|
|
position: absolute;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
padding: 0;
|
|
background: white !important;
|
|
color: black !important;
|
|
}
|
|
.card, header, .toolbar, .status-pill, .progress-container { display: none !important; }
|
|
.main-grid { display: block !important; }
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<header>
|
|
<div class="header-left">
|
|
<h1>
|
|
<i class="bi bi-file-earmark-richtext"></i> Estación de Documentos
|
|
</h1>
|
|
<div
|
|
style="font-size: 0.9rem; color: var(--text-secondary); border-left: 2px solid var(--border-color); padding-left: 1rem;">
|
|
Arrastra PDF, DOCX, TXT, Audio, Video
|
|
</div>
|
|
</div>
|
|
|
|
<div class="header-actions">
|
|
<button class="btn-main" id="configBtn" title="Configuración">
|
|
<i class="bi bi-gear"></i>
|
|
</button>
|
|
<div style="position: relative; display: inline-block;">
|
|
<button class="btn-main primary" id="exportBtn">
|
|
<i class="bi bi-box-arrow-up"></i> Exportar <i class="bi bi-chevron-down"></i>
|
|
</button>
|
|
<div id="exportMenu" style="display:none; position:absolute; right:0; top:100%; background:var(--bg-card); border:1px solid var(--border-color); border-radius:8px; box-shadow:var(--shadow-lg); z-index:1000; min-width:150px; margin-top:5px;">
|
|
<button class="btn-tool" id="downloadMd" style="width:100%; text-align:left; padding:10px;"><i class="bi bi-filetype-md"></i> Markdown (.md)</button>
|
|
<button class="btn-tool" id="downloadHtml" style="width:100%; text-align:left; padding:10px;"><i class="bi bi-filetype-html"></i> HTML (.html)</button>
|
|
<button class="btn-tool" id="downloadPdf" style="width:100%; text-align:left; padding:10px;"><i class="bi bi-filetype-pdf"></i> PDF (Imprimir)</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="main-grid">
|
|
<!-- LEFT: EDITOR -->
|
|
<div class="card" id="dropZone">
|
|
<div class="card-header">
|
|
<span class="card-title">Documentos (Entrada)</span>
|
|
<span class="card-subtitle" id="inputStatus">Listo</span>
|
|
</div>
|
|
|
|
<div class="tabs-container" id="tabsContainer">
|
|
<!-- Tabs will be injected here -->
|
|
</div>
|
|
|
|
<div class="editor-container">
|
|
<div class="toolbar" id="customToolbar">
|
|
<button class="btn-tool" onclick="document.getElementById('fileInput').click()" title="Importar">
|
|
<i class="bi bi-upload"></i> Importar
|
|
</button>
|
|
<div style="width: 1px; background: var(--border-color); margin: 0 0.5rem;"></div>
|
|
<button class="btn-tool" data-format="bold" title="Negrita"><i class="bi bi-type-bold"></i></button>
|
|
<button class="btn-tool" data-format="italic" title="Cursiva"><i class="bi bi-type-italic"></i></button>
|
|
<button class="btn-tool" data-format="h1" title="Título 1"><i class="bi bi-type-h1"></i></button>
|
|
<button class="btn-tool" data-format="h2" title="Título 2"><i class="bi bi-type-h2"></i></button>
|
|
<button class="btn-tool" data-format="bullet" title="Lista"><i class="bi bi-list-ul"></i></button>
|
|
<button class="btn-tool" data-format="table" title="Tabla"><i class="bi bi-table"></i></button>
|
|
<div style="flex:1"></div>
|
|
<div style="display: flex; align-items: center; background: var(--toolbar-bg); border-radius: 6px; border: 1px solid var(--border-color);">
|
|
<button class="btn-tool" id="lineWrapBtn" title="Ajuste de líneas (quitar saltos sin punto final)" style="border:none; padding-right: 5px;"><i class="bi bi-text-paragraph"></i> Ajuste</button>
|
|
<button class="btn-tool" id="undoLineWrapBtn" title="Revertir ajuste" style="border:none; border-left: 1px solid var(--border-color); padding-left: 5px; padding-right: 5px; display:none;"><i class="bi bi-arrow-counterclockwise"></i></button>
|
|
</div>
|
|
<button class="btn-tool" id="llmFixBtn" title="Interpretar formato LLM (arreglar \n)"><i
|
|
class="bi bi-robot"></i> LLM</button>
|
|
<div style="width: 1px; background: var(--border-color); margin: 0 0.5rem;"></div>
|
|
<button class="btn-tool" id="findReplaceToggle" title="Buscar y Reemplazar"><i class="bi bi-search"></i> Buscar</button>
|
|
<button class="btn-tool" id="clearBtn" title="Limpiar"><i class="bi bi-trash"></i></button>
|
|
</div>
|
|
|
|
<div class="find-replace-bar" id="findReplaceBar">
|
|
<input type="text" id="findInput" placeholder="Buscar...">
|
|
<input type="text" id="replaceInput" placeholder="Reemplazar con...">
|
|
<button class="btn-tool" id="replaceBtn">Reemplazar</button>
|
|
<button class="btn-tool" id="replaceAllBtn">Todo</button>
|
|
<button class="btn-tool" onclick="document.getElementById('findReplaceBar').style.display='none'"><i class="bi bi-x"></i></button>
|
|
</div>
|
|
|
|
<div style="position: relative; flex: 1; display: flex; flex-direction: column;">
|
|
<div id="inputIndicator" class="sync-indicator"></div>
|
|
<textarea id="markdownInput" placeholder="Escribe markdown aquí o arrastra archivos..."></textarea>
|
|
</div>
|
|
|
|
<!-- Hidden components -->
|
|
<input type="file" id="fileInput" accept=".docx,.txt,.md,.pdf,audio/*,video/*,.mp3,.wav,.m4a,.mp4,.webm,.ogg,.flac,.mov,.mkv,.avi" multiple style="display: none;">
|
|
<div class="drop-overlay">
|
|
<div class="drop-message">
|
|
<i class="bi bi-cloud-upload" style="font-size: 3rem; margin-bottom: 1rem; display: block;"></i>
|
|
Suelta para Importar<br>
|
|
<span style="font-size: 0.9rem; font-weight: 400; opacity: 0.8">PDF, DOCX, TXT, Audio, Video</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-container">
|
|
<div class="progress-bar" id="progressBar"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- RIGHT: PREVIEW -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<span class="card-title">Vista Previa (Rich Text)</span>
|
|
<span class="card-subtitle" id="previewStatus">Sincronizado</span>
|
|
</div>
|
|
<div class="preview-container" style="position: relative;">
|
|
<div id="previewIndicator" class="sync-indicator"></div>
|
|
<div id="preview" class="markdown-body preview-content" contenteditable="true"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast/Status -->
|
|
<div id="statusPill" class="status-pill"></div>
|
|
|
|
<!-- OCR MODAL -->
|
|
<dialog id="ocrModal">
|
|
<div class="modal-body">
|
|
<div class="modal-header">
|
|
<i class="bi bi-eye"></i> Procesamiento de PDF
|
|
</div>
|
|
<p style="color: var(--text-secondary); margin-bottom: 1.5rem; font-size: 0.95rem;" id="ocrModalMsg">
|
|
Este PDF parece contener imágenes o ser un escaneo. ¿Cómo deseas procesarlo?
|
|
</p>
|
|
|
|
<div class="form-row">
|
|
<label>Páginas a procesar:</label>
|
|
<div class="form-radio">
|
|
<input type="radio" name="pageSelection" id="allPages" value="all" checked>
|
|
<label for="allPages" style="margin:0; cursor:pointer">Todas (<span
|
|
id="totalPages">0</span>)</label>
|
|
</div>
|
|
<div class="form-radio">
|
|
<input type="radio" name="pageSelection" id="customPages" value="custom">
|
|
<label for="customPages" style="margin:0; cursor:pointer">Páginas específicas</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="customRangeInput" style="display:none; margin-top: 1rem;">
|
|
<input type="text" id="pageRanges" placeholder="Ej: 1-5, 8">
|
|
<small style="display:block; margin-top:0.5rem; color: var(--text-secondary)">Separa rangos con
|
|
comas.</small>
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button class="btn-tool" onclick="document.getElementById('ocrModal').close()">Cancelar</button>
|
|
<button class="btn-main" id="startStandardBtn">Procesamiento Estándar</button>
|
|
<button class="btn-main primary" id="startOCRBtn">Mistral OCR (IA)</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<!-- CONFIG MODAL -->
|
|
<dialog id="configModal">
|
|
<div class="modal-body">
|
|
<div class="modal-header">
|
|
<i class="bi bi-gear"></i> Configuración
|
|
</div>
|
|
<div class="form-row">
|
|
<label for="apiKeyInput">Mistral API Key (para OCR):</label>
|
|
<input type="text" id="apiKeyInput" placeholder="Ingresa tu API Key aquí...">
|
|
<small style="display:block; margin-top:0.5rem; color: var(--text-secondary)">
|
|
La llave se guarda localmente en tu navegador.
|
|
</small>
|
|
</div>
|
|
<div class="form-row">
|
|
<label for="deepgramApiKeyInput">Deepgram API Key (para transcribir audio):</label>
|
|
<input type="text" id="deepgramApiKeyInput" placeholder="Ingresa tu API Key de Deepgram aquí...">
|
|
<small style="display:block; margin-top:0.5rem; color: var(--text-secondary)">
|
|
La llave se guarda localmente en tu navegador.
|
|
</small>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn-tool" onclick="document.getElementById('configModal').close()">Cancelar</button>
|
|
<button class="btn-main primary" id="saveConfigBtn">Guardar</button>
|
|
</div>
|
|
</div>
|
|
</dialog>
|
|
|
|
<script>
|
|
// --- CONFIGURATION ---
|
|
let MISTRAL_API_KEY = localStorage.getItem('mistral_api_key') || '';
|
|
let DEEPGRAM_API_KEY = localStorage.getItem('deepgram_api_key') || '';
|
|
|
|
if (typeof pdfjsLib !== 'undefined') {
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/[email protected]/build/pdf.worker.min.js';
|
|
}
|
|
|
|
// --- DOM Elements ---
|
|
const markdownInput = document.getElementById('markdownInput');
|
|
const preview = document.getElementById('preview');
|
|
const dropZone = document.getElementById('dropZone');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const progressBar = document.getElementById('progressBar');
|
|
const statusPill = document.getElementById('statusPill');
|
|
const inputStatus = document.getElementById('inputStatus');
|
|
const previewStatus = document.getElementById('previewStatus');
|
|
const tabsContainer = document.getElementById('tabsContainer');
|
|
const inputIndicator = document.getElementById('inputIndicator');
|
|
const previewIndicator = document.getElementById('previewIndicator');
|
|
|
|
let currentPDFDoc = null;
|
|
let currentPDFFile = null;
|
|
let lastMarkdownState = null;
|
|
|
|
// --- TABS STATE ---
|
|
let documents = []; // { id, name, content }
|
|
let activeDocId = null;
|
|
|
|
// --- 1. INITIALIZATION & SYNC ---
|
|
marked.use({ gfm: true, breaks: true });
|
|
|
|
const turndownService = new TurndownService({
|
|
headingStyle: 'atx',
|
|
codeBlockStyle: 'fenced'
|
|
});
|
|
if (typeof turndownPluginGfm !== 'undefined') {
|
|
turndownService.use(turndownPluginGfm.gfm);
|
|
}
|
|
|
|
// Listener para el input de Markdown
|
|
markdownInput.addEventListener('input', () => {
|
|
if (activeDocId !== null) {
|
|
const doc = documents.find(d => d.id === activeDocId);
|
|
if (doc) doc.content = markdownInput.value;
|
|
}
|
|
updatePreview();
|
|
});
|
|
|
|
function updatePreview() {
|
|
const raw = markdownInput.value;
|
|
let html = marked.parse(raw);
|
|
html = DOMPurify.sanitize(html);
|
|
|
|
html = html.replace(/<table/g, '<div class="table-wrapper"><table');
|
|
html = html.replace(/<\/table>/g, '</table></div>');
|
|
|
|
preview.innerHTML = html;
|
|
|
|
// Auto-guardado
|
|
localStorage.setItem('estacion_docs_content', raw);
|
|
}
|
|
|
|
function updateMarkdownFromPreview() {
|
|
const md = turndownService.turndown(preview.innerHTML);
|
|
markdownInput.value = md;
|
|
if (activeDocId !== null) {
|
|
const doc = documents.find(d => d.id === activeDocId);
|
|
if (doc) doc.content = md;
|
|
}
|
|
localStorage.setItem('estacion_docs_content', md);
|
|
}
|
|
|
|
preview.addEventListener('input', updateMarkdownFromPreview);
|
|
|
|
// --- 2. FILE HANDLING ---
|
|
|
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(evt => {
|
|
window.addEventListener(evt, e => { e.preventDefault(); e.stopPropagation(); });
|
|
});
|
|
|
|
dropZone.addEventListener('dragenter', () => dropZone.classList.add('drop-active'));
|
|
dropZone.addEventListener('dragover', () => dropZone.classList.add('drop-active'));
|
|
dropZone.addEventListener('dragleave', (e) => {
|
|
if (!dropZone.contains(e.relatedTarget)) dropZone.classList.remove('drop-active');
|
|
});
|
|
|
|
dropZone.addEventListener('drop', (e) => {
|
|
dropZone.classList.remove('drop-active');
|
|
if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
fileInput.addEventListener('change', (e) => {
|
|
if (e.target.files.length) handleFiles(e.target.files);
|
|
});
|
|
|
|
async function handleFiles(files) {
|
|
setStatus(`📥 Procesando ${files.length} archivo(s)...`, false);
|
|
for (let i = 0; i < files.length; i++) {
|
|
await handleFile(files[i]);
|
|
}
|
|
renderTabs();
|
|
}
|
|
|
|
async function handleFile(file) {
|
|
const ext = file.name.split('.').pop().toLowerCase();
|
|
const fileNameNoExt = file.name.replace(/\.[^/.]+$/, "");
|
|
setProgress(15);
|
|
|
|
try {
|
|
let content = "";
|
|
if (ext === 'txt' || ext === 'md') {
|
|
content = await file.text();
|
|
}
|
|
else if (ext === 'docx') {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const result = await mammoth.convertToHtml({ arrayBuffer });
|
|
content = turndownService.turndown(result.value);
|
|
}
|
|
else if (ext === 'pdf') {
|
|
content = await processPDF(file);
|
|
}
|
|
else if (isAudioFile(file, ext)) {
|
|
content = await processAudio(file);
|
|
}
|
|
else {
|
|
setStatus('⚠️ Formato no reconocido: ' + file.name);
|
|
return;
|
|
}
|
|
|
|
if (content !== undefined) {
|
|
const newDoc = {
|
|
id: Date.now() + Math.random(),
|
|
name: fileNameNoExt,
|
|
content: content
|
|
};
|
|
documents.push(newDoc);
|
|
setActiveTab(newDoc.id);
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
setStatus('❌ Error en ' + file.name + ': ' + err.message);
|
|
}
|
|
setProgress(100);
|
|
}
|
|
|
|
function isAudioFile(file, ext) {
|
|
return file.type.startsWith('audio/') || ['mp3', 'wav', 'm4a', 'mp4', 'webm', 'ogg', 'flac'].includes(ext);
|
|
}
|
|
|
|
async function processAudio(file) {
|
|
if (!DEEPGRAM_API_KEY) {
|
|
configModal.showModal();
|
|
throw new Error('API Key de Deepgram requerida. Guárdala en configuración.');
|
|
}
|
|
|
|
setStatus('🎙️ Transcribiendo audio con Deepgram...', false);
|
|
setProgress(20);
|
|
|
|
const contentType = file.type || getAudioContentType(file.name);
|
|
const response = await fetch('https://api.deepgram.com/v1/listen?model=nova-3&smart_format=true&language=es', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Token ${DEEPGRAM_API_KEY}`,
|
|
'Content-Type': contentType
|
|
},
|
|
body: file
|
|
});
|
|
|
|
setProgress(80);
|
|
|
|
if (response.status === 401) {
|
|
configModal.showModal();
|
|
throw new Error('API Key de Deepgram inválida. Revísala en configuración.');
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errData = await response.json().catch(() => ({}));
|
|
throw new Error(errData.err_msg || errData.error || `Error ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const alternative = data.results?.channels?.[0]?.alternatives?.[0];
|
|
const transcript = alternative?.paragraphs?.transcript?.trim() || alternative?.transcript?.trim();
|
|
|
|
if (!transcript) throw new Error('Deepgram no devolvió transcripción.');
|
|
|
|
const confidence = typeof alternative.confidence === 'number'
|
|
? `\n\n_Confianza estimada: ${(alternative.confidence * 100).toFixed(1)}%_`
|
|
: '';
|
|
|
|
setStatus('✅ Audio transcrito');
|
|
return `# Transcripción: ${file.name}\n\n${transcript}${confidence}\n`;
|
|
}
|
|
|
|
function getAudioContentType(filename) {
|
|
const ext = filename.split('.').pop().toLowerCase();
|
|
const types = {
|
|
mp3: 'audio/mpeg',
|
|
wav: 'audio/wav',
|
|
m4a: 'audio/mp4',
|
|
mp4: 'audio/mp4',
|
|
webm: 'audio/webm',
|
|
ogg: 'audio/ogg',
|
|
flac: 'audio/flac'
|
|
};
|
|
return types[ext] || 'application/octet-stream';
|
|
}
|
|
|
|
function renderTabs() {
|
|
tabsContainer.innerHTML = '';
|
|
documents.forEach(doc => {
|
|
const tab = document.createElement('div');
|
|
tab.className = `tab ${doc.id === activeDocId ? 'active' : ''}`;
|
|
tab.onclick = () => setActiveTab(doc.id);
|
|
|
|
const name = document.createElement('span');
|
|
name.textContent = doc.name;
|
|
|
|
const close = document.createElement('i');
|
|
close.className = 'bi bi-x tab-close';
|
|
close.onclick = (e) => {
|
|
e.stopPropagation();
|
|
closeTab(doc.id);
|
|
};
|
|
|
|
tab.appendChild(name);
|
|
tab.appendChild(close);
|
|
tabsContainer.appendChild(tab);
|
|
});
|
|
}
|
|
|
|
function setActiveTab(id) {
|
|
activeDocId = id;
|
|
const doc = documents.find(d => d.id === id);
|
|
if (doc) {
|
|
markdownInput.value = doc.content;
|
|
updatePreview();
|
|
}
|
|
renderTabs();
|
|
}
|
|
|
|
function closeTab(id) {
|
|
const index = documents.findIndex(d => d.id === id);
|
|
if (index !== -1) {
|
|
documents.splice(index, 1);
|
|
if (activeDocId === id) {
|
|
activeDocId = documents.length > 0 ? documents[documents.length - 1].id : null;
|
|
}
|
|
if (activeDocId !== null) {
|
|
setActiveTab(activeDocId);
|
|
} else {
|
|
markdownInput.value = '';
|
|
updatePreview();
|
|
renderTabs();
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 3. PDF PROCESSING ---
|
|
async function processPDF(file) {
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
|
|
currentPDFDoc = pdf;
|
|
currentPDFFile = file;
|
|
|
|
// Check for images or low text
|
|
let hasImages = false;
|
|
let totalTextLength = 0;
|
|
|
|
for (let i = 1; i <= Math.min(pdf.numPages, 3); i++) {
|
|
const page = await pdf.getPage(i);
|
|
const content = await page.getTextContent();
|
|
totalTextLength += content.items.reduce((acc, item) => acc + item.str.trim().length, 0);
|
|
|
|
const ops = await page.getOperatorList();
|
|
for (let j = 0; j < ops.fnArray.length; j++) {
|
|
if (ops.fnArray[j] === pdfjsLib.OPS.paintImageXObject ||
|
|
ops.fnArray[j] === pdfjsLib.OPS.paintInlineImageXObject) {
|
|
hasImages = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasImages) break;
|
|
}
|
|
|
|
const isScanned = totalTextLength < 50;
|
|
|
|
if (isScanned || hasImages) {
|
|
document.getElementById('totalPages').textContent = pdf.numPages;
|
|
document.getElementById('ocrModalMsg').textContent = isScanned
|
|
? "Este PDF parece ser un escaneo. ¿Deseas usar IA para extraer el texto?"
|
|
: "Este PDF contiene imágenes. ¿Deseas usar Mistral OCR para una mejor extracción de tablas y gráficos?";
|
|
|
|
// We wrap the modal in a promise to allow the sequential handleFiles to wait
|
|
return new Promise((resolve) => {
|
|
const modal = document.getElementById('ocrModal');
|
|
|
|
const onStandard = async () => {
|
|
cleanup();
|
|
const md = await runStandardPDFProcessing(pdf);
|
|
resolve(md);
|
|
};
|
|
|
|
const onOCR = () => {
|
|
cleanup();
|
|
// OCR logic will create its own tab and handle everything
|
|
// We resolve with undefined so handleFile doesn't create a duplicate tab
|
|
startOCRProcess();
|
|
resolve(undefined);
|
|
};
|
|
|
|
const onCancel = () => {
|
|
cleanup();
|
|
resolve(undefined);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
document.getElementById('startStandardBtn').removeEventListener('click', onStandard);
|
|
document.getElementById('startOCRBtn').removeEventListener('click', onOCR);
|
|
modal.removeEventListener('close', onCancel);
|
|
modal.close();
|
|
};
|
|
|
|
document.getElementById('startStandardBtn').addEventListener('click', onStandard);
|
|
document.getElementById('startOCRBtn').addEventListener('click', onOCR);
|
|
modal.addEventListener('close', onCancel);
|
|
|
|
modal.showModal();
|
|
setStatus('👁️ PDF complejo detectado', false);
|
|
});
|
|
} else {
|
|
return await runStandardPDFProcessing(pdf);
|
|
}
|
|
}
|
|
|
|
async function runStandardPDFProcessing(pdf) {
|
|
let fullScrapedText = "";
|
|
for (let i = 1; i <= pdf.numPages; i++) {
|
|
setStatus(`📄 Analizando pág ${i}/${pdf.numPages}...`, false);
|
|
setProgress((i / pdf.numPages) * 100);
|
|
|
|
const page = await pdf.getPage(i);
|
|
const textContent = await page.getTextContent();
|
|
|
|
const items = textContent.items.sort((a, b) => {
|
|
if (Math.abs(a.transform[5] - b.transform[5]) > 5) return b.transform[5] - a.transform[5];
|
|
return a.transform[4] - b.transform[4];
|
|
});
|
|
|
|
let pageText = "";
|
|
let lastY = null;
|
|
items.forEach(item => {
|
|
const currentY = item.transform[5];
|
|
if (lastY !== null && Math.abs(lastY - currentY) > 5) pageText += '\n';
|
|
pageText += item.str + (item.str.trim() ? " " : "");
|
|
lastY = currentY;
|
|
});
|
|
fullScrapedText += pageText + "\n\n";
|
|
}
|
|
|
|
const structure = analyzeTextStructure(fullScrapedText);
|
|
const markdown = convertStructureToMarkdown(structure);
|
|
setStatus('✅ PDF procesado');
|
|
return markdown;
|
|
}
|
|
|
|
// --- TABLE DETECTION LOGIC ---
|
|
function analyzeTextStructure(textContent) {
|
|
const lines = textContent.split('\n').filter(line => line.trim());
|
|
const analysis = { tables: [], paragraphs: [] };
|
|
let lineIndex = 0;
|
|
|
|
while (lineIndex < lines.length) {
|
|
const line = lines[lineIndex].trim();
|
|
|
|
// Detect Table
|
|
if (detectTableRow(line) && lineIndex < lines.length - 1) {
|
|
const tableRows = [line];
|
|
let nextIdx = lineIndex + 1;
|
|
while (nextIdx < lines.length && detectTableRow(lines[nextIdx])) {
|
|
tableRows.push(lines[nextIdx]);
|
|
nextIdx++;
|
|
}
|
|
if (tableRows.length >= 2) {
|
|
analysis.tables.push({ rows: tableRows, startLine: lineIndex });
|
|
lineIndex = nextIdx;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
analysis.paragraphs.push({ text: line, lineIndex });
|
|
lineIndex++;
|
|
}
|
|
return analysis;
|
|
}
|
|
|
|
function detectTableRow(line) {
|
|
// Split by 2+ spaces or tabs, ensure 2+ cols
|
|
const parts = line.trim().split(/\s{2,}|\t/);
|
|
return parts.length >= 2 && parts.every(p => p.trim().length > 0);
|
|
}
|
|
|
|
function convertStructureToMarkdown(structure) {
|
|
let all = [];
|
|
structure.tables.forEach(t => all.push({ ...t, type: 'table' }));
|
|
structure.paragraphs.forEach(p => all.push({ ...p, type: 'paragraph' }));
|
|
|
|
all.sort((a, b) => (a.lineIndex || a.startLine) - (b.lineIndex || b.startLine));
|
|
|
|
let md = "";
|
|
all.forEach(el => {
|
|
if (el.type === 'table') {
|
|
const rowsData = el.rows.map(r => r.trim().split(/\s{2,}|\t/).map(c => c.trim()));
|
|
if (rowsData.length > 0) {
|
|
const header = rowsData[0];
|
|
md += `\n| ${header.join(' | ')} |\n`;
|
|
md += `| ${header.map(() => '---').join(' | ')} |\n`;
|
|
for (let i = 1; i < rowsData.length; i++) md += `| ${rowsData[i].join(' | ')} |\n`;
|
|
md += "\n";
|
|
}
|
|
} else {
|
|
md += `${el.text}\n\n`;
|
|
}
|
|
});
|
|
return md;
|
|
}
|
|
|
|
// --- OCR LOGIC ---
|
|
const customPagesRadio = document.getElementById('customPages');
|
|
const allPagesRadio = document.getElementById('allPages');
|
|
const customRangeInput = document.getElementById('customRangeInput');
|
|
customPagesRadio.addEventListener('change', () => customRangeInput.style.display = 'block');
|
|
allPagesRadio.addEventListener('change', () => customRangeInput.style.display = 'none');
|
|
|
|
async function startOCRProcess() {
|
|
setStatus('🤖 Iniciando OCR...', false);
|
|
|
|
const fileNameNoExt = currentPDFFile.name.replace(/\.[^/.]+$/, "");
|
|
const newDoc = { id: Date.now() + Math.random(), name: fileNameNoExt + " (OCR)", content: "⌛ Procesando OCR..." };
|
|
documents.push(newDoc);
|
|
setActiveTab(newDoc.id);
|
|
renderTabs();
|
|
|
|
let pages = [];
|
|
if (allPagesRadio.checked) {
|
|
pages = Array.from({ length: currentPDFDoc.numPages }, (_, i) => i + 1);
|
|
} else {
|
|
const val = document.getElementById('pageRanges').value;
|
|
val.split(',').forEach(p => {
|
|
if (p.includes('-')) {
|
|
const [s, e] = p.split('-').map(Number);
|
|
for (let i = s; i <= e; i++) pages.push(i);
|
|
} else pages.push(Number(p));
|
|
});
|
|
}
|
|
|
|
let finalText = "";
|
|
try {
|
|
for (let i = 0; i < pages.length; i++) {
|
|
const pageNum = pages[i];
|
|
if (!documents.find(d => d.id === newDoc.id)) break; // Stop if tab closed
|
|
|
|
setStatus(`👁️ OCR pág ${pageNum}...`, false);
|
|
setProgress(((i + 1) / pages.length) * 100);
|
|
|
|
const img = await renderPageAsImage(currentPDFDoc, pageNum);
|
|
const text = await mistralOCR(img);
|
|
finalText += (finalText ? "\n\n" : "") + `## Página ${pageNum}\n\n${text}`;
|
|
|
|
const doc = documents.find(d => d.id === newDoc.id);
|
|
if (doc) {
|
|
doc.content = finalText;
|
|
if (activeDocId === newDoc.id) {
|
|
markdownInput.value = finalText;
|
|
updatePreview();
|
|
}
|
|
}
|
|
}
|
|
setStatus('✅ OCR Terminado');
|
|
} catch (e) {
|
|
console.error(e);
|
|
const doc = documents.find(d => d.id === newDoc.id);
|
|
if (doc) {
|
|
doc.content += "\n\n❌ Error OCR: " + e.message;
|
|
if (activeDocId === newDoc.id) {
|
|
markdownInput.value = doc.content;
|
|
updatePreview();
|
|
}
|
|
}
|
|
setStatus('❌ Error en OCR');
|
|
}
|
|
}
|
|
|
|
async function renderPageAsImage(pdf, num) {
|
|
const page = await pdf.getPage(num);
|
|
const viewport = page.getViewport({ scale: 2.0 }); // Aumentamos escala para mejor OCR
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = viewport.width;
|
|
canvas.height = viewport.height;
|
|
const ctx = canvas.getContext('2d');
|
|
await page.render({ canvasContext: ctx, viewport }).promise;
|
|
// Mistral acepta base64 puro o URL. Usaremos el formato data:image/jpeg;base64,...
|
|
return canvas.toDataURL('image/jpeg', 0.7);
|
|
}
|
|
|
|
async function mistralOCR(dataUrl) {
|
|
try {
|
|
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${MISTRAL_API_KEY}`
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'pixtral-12b-2409',
|
|
messages: [{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: 'Extract all text from this image and format it as Markdown. If there are tables, represent them using Markdown table syntax. Do not include any commentary or introductions, just the document content.'
|
|
},
|
|
{
|
|
type: 'image_url',
|
|
image_url: dataUrl // dataUrl ya incluye 'data:image/jpeg;base64,...'
|
|
}
|
|
]
|
|
}],
|
|
max_tokens: 2000
|
|
})
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
configModal.showModal();
|
|
throw new Error("API Key inválida. Por favor revísala en configuración.");
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const errData = await res.json().catch(() => ({}));
|
|
throw new Error(errData.error?.message || `Error ${res.status}`);
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (!data.choices || !data.choices[0]) throw new Error("Respuesta de API vacía");
|
|
|
|
return data.choices[0].message.content;
|
|
} catch (error) {
|
|
console.error("Mistral API Error:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// --- TOOLS & UTILS ---
|
|
document.getElementById('llmFixBtn').addEventListener('click', () => {
|
|
const current = markdownInput.value;
|
|
const fixed = current.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
|
markdownInput.value = fixed;
|
|
updatePreview();
|
|
setStatus('🤖 Formato LLM corregido');
|
|
});
|
|
|
|
document.getElementById('lineWrapBtn').addEventListener('click', () => {
|
|
const current = markdownInput.value;
|
|
lastMarkdownState = current; // Guardar para revertir
|
|
document.getElementById('undoLineWrapBtn').style.display = 'block';
|
|
|
|
// Explicación de la lógica:
|
|
// Buscamos saltos de línea (\n) que NO estén precedidos por un punto (.)
|
|
// Los reemplazamos por un espacio.
|
|
// Usamos un regex que mira atrás (lookbehind) si el motor lo soporta,
|
|
// o simplemente una búsqueda que capture el carácter previo si no es un punto.
|
|
|
|
// Lógica: Dividir por líneas y unir si la línea anterior no termina en punto.
|
|
const lines = current.split('\n');
|
|
let result = "";
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let line = lines[i];
|
|
let nextLine = lines[i+1];
|
|
|
|
result += line;
|
|
|
|
if (nextLine !== undefined) {
|
|
const trimmedLine = line.trim();
|
|
if (trimmedLine.length > 0 && !trimmedLine.endsWith('.')) {
|
|
result += " "; // Reemplazamos salto de línea por espacio
|
|
} else {
|
|
result += "\n"; // Mantenemos el salto de línea
|
|
}
|
|
}
|
|
}
|
|
|
|
markdownInput.value = result;
|
|
updatePreview();
|
|
setStatus('✅ Ajuste de líneas completado');
|
|
});
|
|
|
|
document.getElementById('undoLineWrapBtn').addEventListener('click', () => {
|
|
if (lastMarkdownState !== null) {
|
|
markdownInput.value = lastMarkdownState;
|
|
updatePreview();
|
|
lastMarkdownState = null;
|
|
document.getElementById('undoLineWrapBtn').style.display = 'none';
|
|
setStatus('↪ Ajuste revertido');
|
|
}
|
|
});
|
|
|
|
document.getElementById('clearBtn').addEventListener('click', () => {
|
|
markdownInput.value = '';
|
|
updatePreview();
|
|
});
|
|
|
|
document.querySelectorAll('button[data-format]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const fmt = btn.dataset.format;
|
|
const start = markdownInput.selectionStart;
|
|
const end = markdownInput.selectionEnd;
|
|
const text = markdownInput.value;
|
|
const sel = text.substring(start, end);
|
|
let insert = '';
|
|
|
|
switch (fmt) {
|
|
case 'bold': insert = `**${sel}**`; break;
|
|
case 'italic': insert = `*${sel}*`; break;
|
|
case 'h1': insert = `# ${sel}`; break;
|
|
case 'h2': insert = `## ${sel}`; break;
|
|
case 'bullet': insert = `- ${sel}`; break;
|
|
case 'table': insert = `\n| H1 | H2 |\n|---|---|\n| C1 | C2 |\n`; break;
|
|
}
|
|
|
|
markdownInput.setRangeText(insert, start, end, 'select');
|
|
updatePreview();
|
|
markdownInput.focus();
|
|
});
|
|
});
|
|
|
|
// --- FIND & REPLACE ---
|
|
const findReplaceBar = document.getElementById('findReplaceBar');
|
|
const findInput = document.getElementById('findInput');
|
|
const replaceInput = document.getElementById('replaceInput');
|
|
const replaceBtn = document.getElementById('replaceBtn');
|
|
const replaceAllBtn = document.getElementById('replaceAllBtn');
|
|
|
|
document.getElementById('findReplaceToggle').addEventListener('click', () => {
|
|
findReplaceBar.style.display = findReplaceBar.style.display === 'none' ? 'flex' : 'none';
|
|
if (findReplaceBar.style.display === 'flex') findInput.focus();
|
|
});
|
|
|
|
replaceBtn.addEventListener('click', () => {
|
|
const findText = findInput.value;
|
|
const replaceText = replaceInput.value;
|
|
if (!findText) return;
|
|
|
|
const content = markdownInput.value;
|
|
const start = markdownInput.selectionStart;
|
|
const index = content.indexOf(findText, start);
|
|
|
|
if (index !== -1) {
|
|
markdownInput.setRangeText(replaceText, index, index + findText.length, 'select');
|
|
markdownInput.dispatchEvent(new Event('input'));
|
|
} else {
|
|
const firstIndex = content.indexOf(findText);
|
|
if (firstIndex !== -1) {
|
|
markdownInput.setRangeText(replaceText, firstIndex, firstIndex + findText.length, 'select');
|
|
markdownInput.dispatchEvent(new Event('input'));
|
|
}
|
|
}
|
|
});
|
|
|
|
replaceAllBtn.addEventListener('click', () => {
|
|
const findText = findInput.value;
|
|
const replaceText = replaceInput.value;
|
|
if (!findText) return;
|
|
|
|
const content = markdownInput.value;
|
|
const newContent = content.split(findText).join(replaceText);
|
|
markdownInput.value = newContent;
|
|
markdownInput.dispatchEvent(new Event('input'));
|
|
setStatus('✅ Reemplazado todo');
|
|
});
|
|
|
|
// --- BREATHING SYNC INDICATOR ---
|
|
function updateIndicators() {
|
|
const text = markdownInput.value;
|
|
const pos = markdownInput.selectionStart;
|
|
const lines = text.substring(0, pos).split('\n');
|
|
const currentLineNum = lines.length;
|
|
|
|
const lineHeight = 25.6;
|
|
const paddingTop = 24;
|
|
const y = (currentLineNum - 1) * lineHeight + paddingTop;
|
|
|
|
inputIndicator.style.display = 'block';
|
|
inputIndicator.style.top = `${y + 8}px`;
|
|
|
|
const totalLines = text.split('\n').length;
|
|
const percentage = currentLineNum / totalLines;
|
|
const previewScrollHeight = preview.scrollHeight;
|
|
const previewY = percentage * previewScrollHeight;
|
|
|
|
previewIndicator.style.display = 'block';
|
|
previewIndicator.style.top = `${previewY}px`;
|
|
}
|
|
|
|
markdownInput.addEventListener('click', updateIndicators);
|
|
markdownInput.addEventListener('keyup', updateIndicators);
|
|
markdownInput.addEventListener('input', updateIndicators);
|
|
|
|
markdownInput.addEventListener('blur', () => {
|
|
setTimeout(() => {
|
|
inputIndicator.style.display = 'none';
|
|
previewIndicator.style.display = 'none';
|
|
}, 500);
|
|
});
|
|
|
|
// Export logic
|
|
const exportBtn = document.getElementById('exportBtn');
|
|
const exportMenu = document.getElementById('exportMenu');
|
|
|
|
exportBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
exportMenu.style.display = exportMenu.style.display === 'none' ? 'block' : 'none';
|
|
});
|
|
|
|
document.addEventListener('click', () => exportMenu.style.display = 'none');
|
|
|
|
document.getElementById('downloadMd').addEventListener('click', () => {
|
|
const blob = new Blob([markdownInput.value], { type: 'text/markdown' });
|
|
const filename = (documents.find(d => d.id === activeDocId)?.name || 'documento') + '.md';
|
|
downloadFile(blob, filename);
|
|
});
|
|
|
|
document.getElementById('downloadHtml').addEventListener('click', () => {
|
|
const fullHtml = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>Documento Exportado</title>
|
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600&display=swap">
|
|
<style>
|
|
body { font-family: 'Outfit', sans-serif; line-height: 1.6; color: #1e293b; max-width: 800px; margin: 40px auto; padding: 20px; }
|
|
h1, h2, h3 { color: #3b82f6; }
|
|
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
|
|
th, td { border: 1px solid #e2e8f0; padding: 12px; text-align: left; }
|
|
th { background: #f8fafc; }
|
|
code { background: #f1f5f9; padding: 2px 4px; border-radius: 4px; font-family: monospace; }
|
|
</style>
|
|
</head>
|
|
<body>${preview.innerHTML}</body>
|
|
</html>
|
|
`;
|
|
const blob = new Blob([fullHtml], { type: 'text/html' });
|
|
const filename = (documents.find(d => d.id === activeDocId)?.name || 'documento') + '.html';
|
|
downloadFile(blob, filename);
|
|
});
|
|
|
|
document.getElementById('downloadPdf').addEventListener('click', () => {
|
|
window.print();
|
|
});
|
|
|
|
function downloadFile(blob, filename) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// Config logic
|
|
const configModal = document.getElementById('configModal');
|
|
const apiKeyInput = document.getElementById('apiKeyInput');
|
|
const deepgramApiKeyInput = document.getElementById('deepgramApiKeyInput');
|
|
|
|
document.getElementById('configBtn').addEventListener('click', () => {
|
|
apiKeyInput.value = MISTRAL_API_KEY;
|
|
deepgramApiKeyInput.value = DEEPGRAM_API_KEY;
|
|
configModal.showModal();
|
|
});
|
|
|
|
document.getElementById('saveConfigBtn').addEventListener('click', () => {
|
|
MISTRAL_API_KEY = apiKeyInput.value.trim();
|
|
DEEPGRAM_API_KEY = deepgramApiKeyInput.value.trim();
|
|
localStorage.setItem('mistral_api_key', MISTRAL_API_KEY);
|
|
localStorage.setItem('deepgram_api_key', DEEPGRAM_API_KEY);
|
|
configModal.close();
|
|
setStatus('✅ Configuración guardada');
|
|
});
|
|
|
|
function setStatus(msg, autoHide = true) {
|
|
statusPill.innerHTML = msg;
|
|
statusPill.style.display = 'block';
|
|
// Si hay un error (contiene la cruz o error), no lo ocultamos tan rápido
|
|
const delay = msg.includes('❌') || msg.includes('Error') ? 10000 : 4000;
|
|
if (autoHide) setTimeout(() => { statusPill.style.display = 'none'; }, delay);
|
|
}
|
|
function setProgress(p) {
|
|
progressBar.style.width = `${p}%`;
|
|
if (p >= 100) setTimeout(() => progressBar.style.width = '0%', 500);
|
|
}
|
|
|
|
// Init
|
|
const saved = localStorage.getItem('estacion_docs_content');
|
|
if (saved) {
|
|
markdownInput.value = saved;
|
|
// Create a default doc for saved content
|
|
const defaultDoc = { id: Date.now(), name: 'Documento Guardado', content: saved };
|
|
documents.push(defaultDoc);
|
|
activeDocId = defaultDoc.id;
|
|
} else {
|
|
markdownInput.value = "# Listo\n\nArrastra un archivo o escribe aquí.";
|
|
}
|
|
renderTabs();
|
|
updatePreview();
|
|
|
|
</script>
|
|
</body>
|
|
|
|
</html>
|