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>
  );
}