Spaces:
Sleeping
Sleeping
File size: 9,378 Bytes
c6537a8 7a97213 c6537a8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 | // Real LaTeX editor for paper-mode Documents.
// Left: CodeMirror 6 editor with LaTeX (stex) syntax highlighting.
// Right: live LaTeX.js HTML preview (handles \section, \textbf, \emph, lists,
// citations, math via KaTeX — most thesis-shaped documents work).
// Plus a "Compile PDF" action that POSTs to LaTeX-Online (latex.ytotech.com)
// for real pdflatex output in an iframe.
import React, { useState, useEffect, useRef, useMemo } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { StreamLanguage } from '@codemirror/language';
import { stex } from '@codemirror/legacy-modes/mode/stex';
import Icon from './CanvasIcon';
const fireToast = (msg, kind = 'success') =>
window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg, kind } }));
// Wrap a section body as a full standalone .tex document so both LaTeX.js (HTML)
// and LaTeX-Online (PDF) can render it. The user's section content is the body.
const wrapLatexDoc = (body, title = 'Document') => `\\documentclass[11pt]{article}
\\usepackage[utf8]{inputenc}
\\usepackage{amsmath,amssymb}
\\usepackage{graphicx}
\\usepackage{hyperref}
\\title{${title}}
\\begin{document}
${body}
\\end{document}`;
// Render LaTeX source to HTML using latex.js, returned as a sandboxed iframe
// (latex.js renders to a shadow DOM-style document via createGenerator()).
function LatexPreview({ source, title }) {
const containerRef = useRef(null);
const [error, setError] = useState(null);
useEffect(() => {
if (!containerRef.current) return;
let cancelled = false;
setError(null);
(async () => {
try {
// Dynamic import — latex.js is heavy, we only pay the cost when used.
const latexJs = await import('latex.js');
if (cancelled) return;
const { HtmlGenerator, parse } = latexJs;
const generator = new HtmlGenerator({ hyphenate: false });
const doc = parse(wrapLatexDoc(source || '', title), { generator });
if (cancelled || !containerRef.current) return;
containerRef.current.innerHTML = '';
const fragment = doc.htmlDocument().body;
containerRef.current.appendChild(fragment);
} catch (e) {
if (!cancelled) setError(e.message || String(e));
}
})();
return () => { cancelled = true; };
}, [source, title]);
return (
<div className="latex-preview-pane">
{error && (
<div className="latex-preview-error">
<Icon name="alert" size={14}/>
<div>
<div style={{ fontWeight: 600, marginBottom: 2 }}>LaTeX parse error</div>
<div style={{ fontFamily: 'var(--canvas-mono)', fontSize: 11.5 }}>{error}</div>
</div>
</div>
)}
<div ref={containerRef} className="latex-preview-body"/>
</div>
);
}
// PDF compile via LaTeX-Online API. POST the wrapped doc, get a PDF blob back.
function PdfPanel({ source, title, onClose }) {
const [busy, setBusy] = useState(true);
const [pdfUrl, setPdfUrl] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
let blobUrl = null;
(async () => {
setBusy(true);
setError(null);
try {
// YtoTech LaTeX-Online accepts JSON with the .tex source.
// CORS-enabled. Returns PDF binary on success.
const res = await fetch('https://latex.ytotech.com/builds/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
compiler: 'pdflatex',
resources: [{ main: true, content: wrapLatexDoc(source, title) }],
}),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
const blob = await res.blob();
if (cancelled) return;
blobUrl = URL.createObjectURL(blob);
setPdfUrl(blobUrl);
} catch (e) {
if (!cancelled) setError(e.message || String(e));
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
if (blobUrl) URL.revokeObjectURL(blobUrl);
};
}, [source, title]);
return (
<div className="canvas-modal-backdrop" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div className="canvas-modal huge" style={{ maxWidth: 980, height: '85vh', display: 'flex', flexDirection: 'column' }} onClick={(e) => e.stopPropagation()}>
<div className="modal-head">
<div className="modal-icon"><Icon name="flask" size={18}/></div>
<div style={{ flex: 1 }}>
<div className="modal-title">PDF preview · pdflatex</div>
<div className="modal-sub">
Compiled via{' '}
<a href="https://github.com/YtoTech/latex-on-http" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--canvas-accent)' }}>
latex-on-http
</a>
{' '}— a free open-source service. CORS-enabled.
</div>
</div>
{pdfUrl && (
<a className="btn btn-primary" href={pdfUrl} download={`${(title || 'document').replace(/\s+/g, '_')}.pdf`}>
<Icon name="download" size={13}/>Save PDF
</a>
)}
<button className="icon-btn" onClick={onClose}><Icon name="x" size={16}/></button>
</div>
<div className="modal-body" style={{ flex: 1, padding: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{busy && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, color: 'var(--canvas-text-3)' }}>
<div className="spinner" style={{ width: 28, height: 28 }}/>
<div style={{ fontFamily: 'var(--canvas-mono)', fontSize: 12 }}>Running pdflatex…</div>
</div>
)}
{error && (
<div style={{ padding: 20, color: 'var(--canvas-danger)', fontFamily: 'var(--canvas-mono)', fontSize: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--canvas-text)', marginBottom: 6 }}>Compile failed</div>
{error}
<div style={{ marginTop: 10, color: 'var(--canvas-text-3)', fontSize: 11.5, fontFamily: 'var(--canvas-sans)' }}>
The free latex-online service can be flaky and doesn't support every package. The HTML preview to the left should still work for most long-form documents.
</div>
</div>
)}
{pdfUrl && !busy && !error && (
<iframe
src={pdfUrl}
title="PDF preview"
style={{ width: '100%', height: '100%', border: 'none', background: '#525659' }}
/>
)}
</div>
</div>
</div>
);
}
// ============================================================================
// LatexEditor — split-pane editor + preview, with PDF compile button
// ============================================================================
export default function LatexEditor({ value, onChange, title, theme = 'dark' }) {
const [mode, setMode] = useState('split'); // 'split' | 'source' | 'preview'
const [pdfOpen, setPdfOpen] = useState(false);
const extensions = useMemo(() => [
StreamLanguage.define(stex),
], []);
const cmTheme = theme === 'light' ? 'light' : 'dark';
return (
<div className="latex-editor">
<div className="latex-editor-toolbar">
<div className="latex-editor-mode">
<button className={mode === 'split' ? 'active' : ''} onClick={() => setMode('split')} title="Editor + preview side by side">
<Icon name="layout" size={11}/>Split
</button>
<button className={mode === 'source' ? 'active' : ''} onClick={() => setMode('source')} title="Just the LaTeX source">
<Icon name="flask" size={11}/>Source
</button>
<button className={mode === 'preview' ? 'active' : ''} onClick={() => setMode('preview')} title="Just the rendered HTML">
<Icon name="book" size={11}/>Preview
</button>
</div>
<span style={{ flex: 1 }}/>
<button className="btn btn-ghost" onClick={() => setPdfOpen(true)} title="Compile to PDF with real pdflatex">
<Icon name="download" size={13}/>Compile PDF
</button>
</div>
<div className={`latex-editor-panes mode-${mode}`}>
{(mode === 'split' || mode === 'source') && (
<div className="latex-source-pane">
<CodeMirror
value={value}
onChange={onChange}
theme={cmTheme}
extensions={extensions}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: false,
bracketMatching: true,
closeBrackets: true,
}}
placeholder="Write LaTeX here — \\section, \\textbf, $\\frac{a}{b}$, etc."
/>
</div>
)}
{(mode === 'split' || mode === 'preview') && (
<LatexPreview source={value} title={title}/>
)}
</div>
{pdfOpen && <PdfPanel source={value} title={title} onClose={() => setPdfOpen(false)}/>}
</div>
);
}
|