1612 lines
61 KiB
HTML
1612 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/ocr', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${MISTRAL_API_KEY}`
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'mistral-ocr-latest',
|
|
document: {
|
|
type: 'image_url',
|
|
image_url: dataUrl // dataUrl ya incluye 'data:image/jpeg;base64,...'
|
|
},
|
|
include_image_base64: false
|
|
})
|
|
});
|
|
|
|
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();
|
|
const pages = data.pages || [];
|
|
const markdown = pages.map(page => page.markdown || '').filter(Boolean).join('\n\n');
|
|
if (!markdown && !data.markdown) throw new Error("Respuesta de API vacía");
|
|
|
|
return markdown || data.markdown;
|
|
} 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>
|