latex-studio / frontend /static /js /editor.js
SaiPavankumar22's picture
fix: correct initial zoom label (150%), disable download btns until first compile
db258ef
/* Editor Page β€” LaTeX editor with PDF.js preview (no iframe, sandbox-safe) */
const DEFAULT_LATEX = `\\documentclass[12pt,a4paper]{article}
\\usepackage[margin=1in]{geometry}
\\usepackage{amsmath}
\\usepackage{graphicx}
\\usepackage{hyperref}
\\title{My Document}
\\author{Your Name}
\\date{\\today}
\\begin{document}
\\maketitle
\\begin{abstract}
Write your abstract here. This is a brief summary of your work.
\\end{abstract}
\\section{Introduction}
Start writing your introduction here. You can use \\LaTeX{} commands to format your text.
\\section{Main Content}
This is the main content of your document. You can add:
\\begin{itemize}
\\item Bullet points
\\item Mathematics: $E = mc^2$
\\item References and citations
\\end{itemize}
\\subsection{A Subsection}
Add subsections as needed.
\\begin{equation}
\\int_{0}^{\\infty} e^{-x^2} dx = \\frac{\\sqrt{\\pi}}{2}
\\end{equation}
\\section{Conclusion}
Your conclusion goes here.
\\end{document}`;
const SNIPPETS = {
equation: `\\begin{equation}\n f(x) = \\sum_{i=0}^{n} a_i x^i\n\\end{equation}`,
table: `\\begin{table}[h]\n\\centering\n\\begin{tabular}{|l|c|r|}\n\\hline\nHeader 1 & Header 2 & Header 3 \\\\\n\\hline\nRow 1 & Value & Data \\\\\nRow 2 & Value & Data \\\\\n\\hline\n\\end{tabular}\n\\caption{Table caption}\n\\label{tab:mytable}\n\\end{table}`,
figure: `\\begin{figure}[h]\n\\centering\n\\includegraphics[width=0.8\\linewidth]{filename.png}\n\\caption{Figure caption}\n\\label{fig:myfig}\n\\end{figure}`,
itemize: `\\begin{itemize}\n \\item First item\n \\item Second item\n \\item Third item\n\\end{itemize}`,
enumerate: `\\begin{enumerate}\n \\item First item\n \\item Second item\n \\item Third item\n\\end{enumerate}`,
section: `\\section{Section Title}\n\nSection content here.\n\n\\subsection{Subsection}\n\nSubsection content.`,
code: `\\usepackage{listings} % add to preamble\n\n\\begin{lstlisting}[language=Python]\ndef hello_world():\n print("Hello, World!")\n\\end{lstlisting}`,
columns: `\\begin{columns}\n\\begin{column}{0.5\\textwidth}\n Left column content\n\\end{column}\n\\begin{column}{0.5\\textwidth}\n Right column content\n\\end{column}\n\\end{columns}`,
};
// ─── State ────────────────────────────────────────────────────────────────────
let currentPdfBlob = null; // kept for PDF download
let zoomLevel = 1.5; // PDF.js render scale
let templateId = null;
let pdfDoc = null; // loaded PDF.js document
// ─── DOM refs ─────────────────────────────────────────────────────────────────
const codeEditor = document.getElementById('code-editor');
const lineNumbers = document.getElementById('line-numbers');
const statusDot = document.querySelector('.status-dot');
const statusText = document.getElementById('status-text');
const canvasContainer = document.getElementById('pdf-canvas-container');
const previewPlaceholder = document.getElementById('preview-placeholder');
const previewLoading = document.getElementById('preview-loading');
const errorPanel = document.getElementById('error-panel');
const errorList = document.getElementById('error-list');
const docTitle = document.getElementById('doc-title');
// ─── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
const params = new URLSearchParams(location.search);
templateId = params.get('template');
if (templateId) {
await loadTemplate(templateId);
} else {
codeEditor.value = DEFAULT_LATEX;
updateLineNumbers();
}
codeEditor.addEventListener('input', () => {
updateLineNumbers();
setStatus('modified', 'Modified');
});
codeEditor.addEventListener('scroll', syncScroll);
codeEditor.addEventListener('keydown', handleKeydown);
document.getElementById('btn-compile').addEventListener('click', compileDocument);
document.getElementById('placeholder-compile')?.addEventListener('click', compileDocument);
document.getElementById('btn-check').addEventListener('click', checkSyntax);
document.getElementById('btn-pdf').addEventListener('click', () => downloadFile('pdf'));
document.getElementById('btn-docx').addEventListener('click', () => downloadFile('docx'));
document.getElementById('btn-zoom-in').addEventListener('click', () => adjustZoom(0.25));
document.getElementById('btn-zoom-out').addEventListener('click', () => adjustZoom(-0.25));
document.getElementById('btn-fullscreen').addEventListener('click', toggleFullscreen);
document.getElementById('btn-snippet').addEventListener('click', toggleSnippetPanel);
document.getElementById('snippet-close').addEventListener('click', () => {
document.getElementById('snippet-panel').style.display = 'none';
});
document.getElementById('error-close').addEventListener('click', () => {
errorPanel.style.display = 'none';
});
document.getElementById('btn-upload-tex')?.addEventListener('click', () => {
document.getElementById('tex-upload-input').click();
});
document.getElementById('tex-upload-input')?.addEventListener('change', handleTexUpload);
document.querySelectorAll('.snippet-btn').forEach(btn => {
btn.addEventListener('click', () => {
const key = btn.dataset.snippet;
if (SNIPPETS[key]) insertSnippet(SNIPPETS[key]);
document.getElementById('snippet-panel').style.display = 'none';
});
});
initResizer();
document.getElementById('btn-format')?.addEventListener('click', formatCode);
// Disable downloads until first compile
document.getElementById('btn-pdf').disabled = true;
document.getElementById('btn-docx').disabled = true;
// Sync zoom label with initial render scale
document.getElementById('zoom-label').textContent = `${Math.round(zoomLevel * 100)}%`;
updateLineNumbers();
});
// ─── Template loader ──────────────────────────────────────────────────────────
async function loadTemplate(id) {
try {
setStatus('compiling', 'Loading…');
const tpl = await api.getTemplate(id);
codeEditor.value = tpl.latex_content || DEFAULT_LATEX;
docTitle.value = tpl.name || 'Untitled';
updateLineNumbers();
setStatus('', 'Ready');
} catch (err) {
codeEditor.value = DEFAULT_LATEX;
updateLineNumbers();
setStatus('', 'Ready');
showToast(`Could not load template: ${err.message}`, 'error');
}
}
// ─── .tex file upload ─────────────────────────────────────────────────────────
async function handleTexUpload(e) {
const file = e.target.files[0];
if (!file) return;
if (!file.name.endsWith('.tex')) { showToast('Please select a .tex file', 'error'); return; }
const text = await file.text();
codeEditor.value = text;
docTitle.value = file.name.replace('.tex', '');
updateLineNumbers();
setStatus('modified', 'Loaded from file');
showToast(`Loaded ${file.name}`, 'success');
e.target.value = '';
}
// ─── Line Numbers ─────────────────────────────────────────────────────────────
function updateLineNumbers() {
const lines = codeEditor.value.split('\n').length;
lineNumbers.textContent = Array.from({ length: lines }, (_, i) => i + 1).join('\n');
}
function syncScroll() {
lineNumbers.scrollTop = codeEditor.scrollTop;
}
// ─── Status ───────────────────────────────────────────────────────────────────
function setStatus(state, text) {
statusDot.className = 'status-dot' + (state ? ` ${state}` : '');
statusText.textContent = text;
}
// ─── Keyboard Shortcuts ───────────────────────────────────────────────────────
function handleKeydown(e) {
if (e.key === 'Tab') {
e.preventDefault();
const start = codeEditor.selectionStart;
const end = codeEditor.selectionEnd;
codeEditor.value = codeEditor.value.substring(0, start) + ' ' + codeEditor.value.substring(end);
codeEditor.selectionStart = codeEditor.selectionEnd = start + 2;
updateLineNumbers();
}
if ((e.ctrlKey || e.metaKey) && (e.key === 'Enter' || e.key === 's')) {
e.preventDefault();
compileDocument();
}
}
// ─── Compile + PDF.js Render ──────────────────────────────────────────────────
async function compileDocument() {
const latex = codeEditor.value.trim();
if (!latex) { showToast('Editor is empty', 'error'); return; }
setStatus('compiling', 'Compiling…');
previewPlaceholder.style.display = 'none';
previewLoading.style.display = 'flex';
canvasContainer.style.display = 'none';
const compileBtn = document.getElementById('btn-compile');
compileBtn.disabled = true;
try {
// Fetch compiled PDF as a blob (binary download from server)
const blob = await api.compileDocument(latex, 'pdf');
currentPdfBlob = blob;
// Render with PDF.js β€” works in any sandbox, no iframe at all
await renderPdfBlob(blob);
previewLoading.style.display = 'none';
canvasContainer.style.display = 'block';
errorPanel.style.display = 'none';
setStatus('success', 'Compiled');
showToast('Compiled successfully!', 'success');
document.getElementById('btn-pdf').disabled = false;
document.getElementById('btn-docx').disabled = false;
} catch (err) {
previewLoading.style.display = 'none';
previewPlaceholder.style.display = 'flex';
setStatus('error', 'Error');
const errorMsg = err.message || 'Compilation failed';
errorList.innerHTML = errorMsg.split('\n')
.filter(l => l.trim())
.map(l => `<div class="error-item">${escHtml(l)}</div>`)
.join('');
errorPanel.style.display = 'block';
showToast('Compilation failed', 'error');
} finally {
compileBtn.disabled = false;
}
}
// ─── PDF.js renderer ──────────────────────────────────────────────────────────
async function renderPdfBlob(blob) {
const arrayBuffer = await blob.arrayBuffer();
// Load the PDF document
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
pdfDoc = await loadingTask.promise;
canvasContainer.innerHTML = '';
for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: zoomLevel });
const wrapper = document.createElement('div');
wrapper.className = 'pdf-page-wrapper';
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
wrapper.appendChild(canvas);
canvasContainer.appendChild(wrapper);
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
}
}
async function rerenderPdf() {
if (!pdfDoc) return;
canvasContainer.innerHTML = '';
for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: zoomLevel });
const wrapper = document.createElement('div');
wrapper.className = 'pdf-page-wrapper';
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
wrapper.appendChild(canvas);
canvasContainer.appendChild(wrapper);
await page.render({ canvasContext: canvas.getContext('2d'), viewport }).promise;
}
}
// ─── Check Syntax ─────────────────────────────────────────────────────────────
async function checkSyntax() {
const latex = codeEditor.value.trim();
if (!latex) return;
try {
const result = await api.checkLatex(latex);
if (result.valid) {
errorPanel.style.display = 'none';
showToast('βœ“ Syntax looks good', 'success');
setStatus('success', 'Valid');
} else {
errorList.innerHTML = result.errors
.map(e => `<div class="error-item">⚠ ${escHtml(e)}</div>`)
.join('');
errorPanel.style.display = 'block';
setStatus('error', `${result.errors.length} issue(s)`);
}
} catch (err) {
showToast(err.message, 'error');
}
}
// ─── Download ─────────────────────────────────────────────────────────────────
async function downloadFile(format) {
const latex = codeEditor.value.trim();
if (!latex) { showToast('Nothing to export', 'error'); return; }
setStatus('compiling', `Exporting ${format.toUpperCase()}…`);
try {
let blob;
if (format === 'pdf' && currentPdfBlob) {
// Reuse the already-compiled PDF blob β€” no extra server call needed
blob = currentPdfBlob;
} else {
blob = await api.compileDocument(latex, format);
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const name = (docTitle.value || 'document').replace(/[^a-z0-9_-]/gi, '_');
a.download = `${name}.${format}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setStatus('success', 'Downloaded');
showToast(`${format.toUpperCase()} downloaded!`, 'success');
} catch (err) {
setStatus('error', 'Error');
showToast(`Export failed: ${err.message}`, 'error');
}
}
// ─── Zoom (re-renders at new scale) ──────────────────────────────────────────
async function adjustZoom(delta) {
zoomLevel = Math.max(0.5, Math.min(3.0, zoomLevel + delta));
document.getElementById('zoom-label').textContent = `${Math.round(zoomLevel * 100)}%`;
if (pdfDoc) await rerenderPdf();
}
// ─── Fullscreen ───────────────────────────────────────────────────────────────
function toggleFullscreen() {
const codePanel = document.getElementById('code-panel');
const resizer = document.getElementById('resizer');
const previewPanel = document.getElementById('preview-panel');
if (codePanel.style.display === 'none') {
codePanel.style.display = 'flex';
resizer.style.display = '';
previewPanel.style.flex = '';
} else {
codePanel.style.display = 'none';
resizer.style.display = 'none';
previewPanel.style.flex = '1';
}
}
// ─── Snippet ──────────────────────────────────────────────────────────────────
function insertSnippet(text) {
const start = codeEditor.selectionStart;
codeEditor.value = codeEditor.value.substring(0, start) + '\n' + text + '\n' + codeEditor.value.substring(start);
codeEditor.selectionStart = codeEditor.selectionEnd = start + text.length + 2;
codeEditor.focus();
updateLineNumbers();
}
function toggleSnippetPanel() {
const panel = document.getElementById('snippet-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
}
// ─── Format ───────────────────────────────────────────────────────────────────
function formatCode() {
let code = codeEditor.value;
code = code.replace(/([^\n])\n(\\(?:section|subsection|subsubsection|begin|end)\{)/g, '$1\n\n$2');
codeEditor.value = code;
updateLineNumbers();
showToast('Formatted', 'success');
}
// ─── Resizer ──────────────────────────────────────────────────────────────────
function initResizer() {
const resizer = document.getElementById('resizer');
const codePanel = document.getElementById('code-panel');
const layout = document.querySelector('.editor-layout');
let startX, startWidth;
resizer.addEventListener('mousedown', e => {
startX = e.clientX;
startWidth = codePanel.offsetWidth;
resizer.classList.add('dragging');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
});
function onMouseMove(e) {
const newWidth = Math.max(200, Math.min(layout.offsetWidth - 200, startWidth + e.clientX - startX));
codePanel.style.width = newWidth + 'px';
codePanel.style.flex = 'none';
}
function onMouseUp() {
resizer.classList.remove('dragging');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
}
// ─── Utils ────────────────────────────────────────────────────────────────────
function escHtml(str) {
return String(str || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}