diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 960005e..608644f 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -7,6 +7,7 @@ import type { EditorView } from "@codemirror/view"; import { SplitPane } from "@/components/editor/SplitPane"; import { CompileLog } from "@/components/editor/CompileLog"; import { FilesPanel } from "@/components/editor/FilesPanel"; +import { FormatBar } from "@/components/editor/FormatBar"; import { Toolbar, type SaveState } from "@/components/editor/Toolbar"; import { useCompile } from "@/components/editor/useCompile"; import { getEngine } from "@/lib/engines"; @@ -37,6 +38,8 @@ export default function EditorPage({ params }: { params: { id: string } }) { // which file is open in the editor; the main file compiles, the rest are // \input from it const [activeFile, setActiveFile] = useState(""); + // the CodeMirror view, kept in state so the format bar can act on it + const [editorView, setEditorView] = useState(null); const { compile, status, log, durationMs, pdfVersion } = useCompile(id); @@ -126,11 +129,28 @@ export default function EditorPage({ params }: { params: { id: string } }) { }, COMPILE_DEBOUNCE_MS); } - async function manualCompile() { + const manualCompile = useCallback(async () => { clearTimeout(compileTimer.current); await save(); compile(); - } + }, [save, compile]); + + // keyboard shortcuts: save and compile. Mod- means Cmd on macOS and Ctrl on + // Windows and Linux, so this works everywhere. + useEffect(() => { + function onKey(event: KeyboardEvent) { + if (!event.metaKey && !event.ctrlKey) return; + if (event.key === "s") { + event.preventDefault(); + void save(); + } else if (event.key === "Enter") { + event.preventDefault(); + void manualCompile(); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [save, manualCompile]); // switch which file is open: save the current one, then load the chosen file async function openFile(name: string) { @@ -165,6 +185,15 @@ export default function EditorPage({ params }: { params: { id: string } }) { openFile(mainFileRef.current); } + // jump the editor to a line, used when clicking a compile error + function goToLine(lineNumber: number) { + if (!editorView) return; + const target = Math.min(Math.max(lineNumber, 1), editorView.state.doc.lines); + const line = editorView.state.doc.line(target); + editorView.dispatch({ selection: { anchor: line.from }, scrollIntoView: true }); + editorView.focus(); + } + // drop an \includegraphics line for an uploaded image at the cursor function insertImage(name: string) { const view = editorViewRef.current; @@ -232,12 +261,14 @@ export default function EditorPage({ params }: { params: { id: string } }) { +
{ editorViewRef.current = view; + setEditorView(view); }} />
@@ -246,6 +277,7 @@ export default function EditorPage({ params }: { params: { id: string } }) { log={log} status={status} onClose={() => setShowLog(false)} + onGoToLine={goToLine} /> )} diff --git a/src/components/editor/CompileLog.tsx b/src/components/editor/CompileLog.tsx index 9879301..a72c18d 100644 --- a/src/components/editor/CompileLog.tsx +++ b/src/components/editor/CompileLog.tsx @@ -1,17 +1,54 @@ "use client"; import { useEffect, useRef } from "react"; -import { X } from "lucide-react"; +import { AlertCircle, X } from "lucide-react"; import type { CompileStatus } from "./useCompile"; +interface CompileError { + message: string; + line: number | null; +} + +// Pull the "! ..." errors out of a TeX log, pairing each with the "l." line +// reference that follows it when there is one. +function parseErrors(log: string): CompileError[] { + const lines = log.split("\n"); + const errors: CompileError[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!line || !line.startsWith("! ")) continue; + + const message = line.slice(2).replace(/\.$/, "").trim(); + let lineNumber: number | null = null; + for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) { + const match = /^l\.(\d+)/.exec(lines[j] ?? ""); + if (match) { + lineNumber = Number(match[1]); + break; + } + } + errors.push({ message, line: lineNumber }); + } + + return errors; +} + interface CompileLogProps { log: string; status: CompileStatus; onClose: () => void; + onGoToLine: (line: number) => void; } -export function CompileLog({ log, status, onClose }: CompileLogProps) { +export function CompileLog({ + log, + status, + onClose, + onGoToLine, +}: CompileLogProps) { const bodyRef = useRef(null); + const errors = parseErrors(log); // follow the output as it streams in useEffect(() => { @@ -20,10 +57,14 @@ export function CompileLog({ log, status, onClose }: CompileLogProps) { }, [log]); return ( -
+
- {status === "running" ? "Compiling..." : "Compile log"} + {status === "running" + ? "Compiling..." + : errors.length > 0 + ? `${errors.length} error${errors.length === 1 ? "" : "s"}` + : "Compile log"}
+ + {errors.length > 0 && ( +
    + {errors.map((error, index) => ( +
  • + +
  • + ))} +
+ )} +
 void;
+}
+
+const groups: Action[][] = [
+  [
+    { icon: Bold, title: "Bold", run: (v) => wrap(v, "\\textbf{", "}") },
+    { icon: Italic, title: "Italic", run: (v) => wrap(v, "\\textit{", "}") },
+    {
+      icon: Underline,
+      title: "Underline",
+      run: (v) => wrap(v, "\\underline{", "}"),
+    },
+  ],
+  [
+    {
+      icon: Heading1,
+      title: "Section",
+      run: (v) => wrap(v, "\\section{", "}"),
+    },
+    {
+      icon: Heading2,
+      title: "Subsection",
+      run: (v) => wrap(v, "\\subsection{", "}"),
+    },
+  ],
+  [
+    { icon: List, title: "Bulleted list", run: (v) => insertBlock(v, ITEMIZE) },
+    {
+      icon: ListOrdered,
+      title: "Numbered list",
+      run: (v) => insertBlock(v, ENUMERATE),
+    },
+  ],
+  [{ icon: Sigma, title: "Inline math", run: (v) => wrap(v, "$", "$") }],
+];
+
+export function FormatBar({ view }: { view: EditorView | null }) {
+  return (
+    
+ {groups.map((group, index) => ( +
+ {index > 0 && } + {group.map((action) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/src/components/editor/PreviewPane.tsx b/src/components/editor/PreviewPane.tsx index 320eca6..28c81cc 100644 --- a/src/components/editor/PreviewPane.tsx +++ b/src/components/editor/PreviewPane.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { Minus, Plus } from "lucide-react"; import type { CompileStatus } from "./useCompile"; type RenderState = "loading" | "ready" | "blank" | "error"; @@ -13,6 +14,10 @@ interface PreviewPaneProps { documentStatus: CompileStatus; } +const ZOOM_MIN = 0.5; +const ZOOM_MAX = 3; +const ZOOM_STEP = 0.2; + // Renders the compiled PDF with pdf.js, one canvas per page. The worker is // bundled from the package so it keeps working offline. export function PreviewPane({ @@ -23,6 +28,7 @@ export function PreviewPane({ const pagesRef = useRef(null); const scrollRef = useRef(null); const [state, setState] = useState("blank"); + const [zoom, setZoom] = useState(1); useEffect(() => { let cancelled = false; @@ -57,7 +63,8 @@ export function PreviewPane({ const previousScroll = scroller.scrollTop; container.replaceChildren(); - const targetWidth = Math.max(scroller.clientWidth - 48, 320); + const baseWidth = Math.max(scroller.clientWidth - 48, 280); + const targetWidth = baseWidth * zoom; const dpr = window.devicePixelRatio || 1; for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber++) { @@ -97,7 +104,7 @@ export function PreviewPane({ cancelled = true; loadingTask?.destroy(); }; - }, [projectId, version]); + }, [projectId, version, zoom]); // pick the placeholder message for when there's no rendered PDF on screen const placeholder = (() => { @@ -113,6 +120,10 @@ export function PreviewPane({ const showSpinner = state === "loading" || (state === "blank" && documentStatus === "running"); + function changeZoom(delta: number) { + setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Number((z + delta).toFixed(2))))); + } + return (
@@ -126,6 +137,34 @@ export function PreviewPane({ )}
)} + + {state === "ready" && ( +
+ + + +
+ )}
); } diff --git a/src/components/editor/latex.ts b/src/components/editor/latex.ts index 87621be..dce3a46 100644 --- a/src/components/editor/latex.ts +++ b/src/components/editor/latex.ts @@ -10,42 +10,95 @@ import { import { EditorView } from "@codemirror/view"; import type { Extension } from "@codemirror/state"; -// Common commands offered while typing a backslash. The ones with a snippet -// drop the cursor between the braces (${} is the cursor stop) so you can keep -// typing the argument right away. -const commands: { label: string; snippet?: string }[] = [ - { label: "\\documentclass", snippet: "\\documentclass{${}}" }, - { label: "\\usepackage", snippet: "\\usepackage{${}}" }, - { label: "\\begin", snippet: "\\begin{${}}" }, - { label: "\\end", snippet: "\\end{${}}" }, - { label: "\\section", snippet: "\\section{${}}" }, - { label: "\\subsection", snippet: "\\subsection{${}}" }, - { label: "\\subsubsection", snippet: "\\subsubsection{${}}" }, - { label: "\\paragraph", snippet: "\\paragraph{${}}" }, - { label: "\\textbf", snippet: "\\textbf{${}}" }, - { label: "\\textit", snippet: "\\textit{${}}" }, - { label: "\\texttt", snippet: "\\texttt{${}}" }, - { label: "\\emph", snippet: "\\emph{${}}" }, - { label: "\\underline", snippet: "\\underline{${}}" }, - { label: "\\item" }, - { label: "\\label", snippet: "\\label{${}}" }, - { label: "\\ref", snippet: "\\ref{${}}" }, - { label: "\\eqref", snippet: "\\eqref{${}}" }, - { label: "\\cite", snippet: "\\cite{${}}" }, - { label: "\\footnote", snippet: "\\footnote{${}}" }, - { label: "\\caption", snippet: "\\caption{${}}" }, - { label: "\\includegraphics", snippet: "\\includegraphics[width=${}]{${}}" }, - { label: "\\frac", snippet: "\\frac{${}}{${}}" }, - { label: "\\sqrt", snippet: "\\sqrt{${}}" }, - { label: "\\sum", snippet: "\\sum_{${}}^{${}}" }, - { label: "\\int", snippet: "\\int_{${}}^{${}}" }, - { label: "\\title", snippet: "\\title{${}}" }, - { label: "\\author", snippet: "\\author{${}}" }, - { label: "\\date", snippet: "\\date{${}}" }, - { label: "\\maketitle" }, - { label: "\\newcommand", snippet: "\\newcommand{${}}{${}}" }, - { label: "\\hline" }, - { label: "\\newpage" }, +// Commands offered while typing a backslash. snippet drops the cursor between +// the braces (${}); detail is the short hint shown next to the name. This is a +// working everyday set, not the whole of LaTeX, but enough that typing "\geo" +// finds "\geometry" and so on. +interface Command { + label: string; + snippet?: string; + detail?: string; +} + +const commands: Command[] = [ + { label: "\\documentclass", snippet: "\\documentclass{${}}", detail: "document class" }, + { label: "\\usepackage", snippet: "\\usepackage{${}}", detail: "load a package" }, + { label: "\\begin", snippet: "\\begin{${}}", detail: "open an environment" }, + { label: "\\end", snippet: "\\end{${}}", detail: "close an environment" }, + { label: "\\section", snippet: "\\section{${}}", detail: "section" }, + { label: "\\subsection", snippet: "\\subsection{${}}", detail: "subsection" }, + { label: "\\subsubsection", snippet: "\\subsubsection{${}}", detail: "subsubsection" }, + { label: "\\paragraph", snippet: "\\paragraph{${}}", detail: "paragraph heading" }, + { label: "\\chapter", snippet: "\\chapter{${}}", detail: "chapter (book/report)" }, + { label: "\\part", snippet: "\\part{${}}", detail: "part" }, + { label: "\\textbf", snippet: "\\textbf{${}}", detail: "bold" }, + { label: "\\textit", snippet: "\\textit{${}}", detail: "italic" }, + { label: "\\texttt", snippet: "\\texttt{${}}", detail: "monospace" }, + { label: "\\textsc", snippet: "\\textsc{${}}", detail: "small caps" }, + { label: "\\emph", snippet: "\\emph{${}}", detail: "emphasis" }, + { label: "\\underline", snippet: "\\underline{${}}", detail: "underline" }, + { label: "\\textsuperscript", snippet: "\\textsuperscript{${}}", detail: "superscript" }, + { label: "\\item", detail: "list item" }, + { label: "\\item[]", snippet: "\\item[${}] ", detail: "labeled item" }, + { label: "\\label", snippet: "\\label{${}}", detail: "set a label" }, + { label: "\\ref", snippet: "\\ref{${}}", detail: "reference a label" }, + { label: "\\eqref", snippet: "\\eqref{${}}", detail: "reference an equation" }, + { label: "\\pageref", snippet: "\\pageref{${}}", detail: "reference a page" }, + { label: "\\autoref", snippet: "\\autoref{${}}", detail: "typed reference" }, + { label: "\\cite", snippet: "\\cite{${}}", detail: "citation" }, + { label: "\\footnote", snippet: "\\footnote{${}}", detail: "footnote" }, + { label: "\\caption", snippet: "\\caption{${}}", detail: "caption" }, + { label: "\\includegraphics", snippet: "\\includegraphics[width=${}]{${}}", detail: "insert an image" }, + { label: "\\input", snippet: "\\input{${}}", detail: "include another file" }, + { label: "\\include", snippet: "\\include{${}}", detail: "include a file (new page)" }, + { label: "\\href", snippet: "\\href{${}}{${}}", detail: "hyperlink" }, + { label: "\\url", snippet: "\\url{${}}", detail: "URL" }, + { label: "\\frac", snippet: "\\frac{${}}{${}}", detail: "fraction" }, + { label: "\\sqrt", snippet: "\\sqrt{${}}", detail: "square root" }, + { label: "\\sum", snippet: "\\sum_{${}}^{${}}", detail: "summation" }, + { label: "\\prod", snippet: "\\prod_{${}}^{${}}", detail: "product" }, + { label: "\\int", snippet: "\\int_{${}}^{${}}", detail: "integral" }, + { label: "\\lim", snippet: "\\lim_{${}}", detail: "limit" }, + { label: "\\alpha", detail: "greek α" }, + { label: "\\beta", detail: "greek β" }, + { label: "\\gamma", detail: "greek γ" }, + { label: "\\delta", detail: "greek δ" }, + { label: "\\theta", detail: "greek θ" }, + { label: "\\lambda", detail: "greek λ" }, + { label: "\\mu", detail: "greek μ" }, + { label: "\\pi", detail: "greek π" }, + { label: "\\sigma", detail: "greek σ" }, + { label: "\\omega", detail: "greek ω" }, + { label: "\\times", detail: "×" }, + { label: "\\cdot", detail: "·" }, + { label: "\\leq", detail: "≤" }, + { label: "\\geq", detail: "≥" }, + { label: "\\neq", detail: "≠" }, + { label: "\\approx", detail: "≈" }, + { label: "\\infty", detail: "∞" }, + { label: "\\rightarrow", detail: "→" }, + { label: "\\mathbb", snippet: "\\mathbb{${}}", detail: "blackboard bold" }, + { label: "\\mathbf", snippet: "\\mathbf{${}}", detail: "math bold" }, + { label: "\\mathcal", snippet: "\\mathcal{${}}", detail: "calligraphic" }, + { label: "\\text", snippet: "\\text{${}}", detail: "text in math" }, + { label: "\\title", snippet: "\\title{${}}", detail: "title" }, + { label: "\\author", snippet: "\\author{${}}", detail: "author" }, + { label: "\\date", snippet: "\\date{${}}", detail: "date" }, + { label: "\\maketitle", detail: "render the title" }, + { label: "\\tableofcontents", detail: "table of contents" }, + { label: "\\newcommand", snippet: "\\newcommand{${}}{${}}", detail: "define a command" }, + { label: "\\renewcommand", snippet: "\\renewcommand{${}}{${}}", detail: "redefine a command" }, + { label: "\\geometry", snippet: "\\geometry{${}}", detail: "page geometry" }, + { label: "\\textcolor", snippet: "\\textcolor{${}}{${}}", detail: "colored text" }, + { label: "\\hline", detail: "table rule" }, + { label: "\\midrule", detail: "booktabs rule" }, + { label: "\\toprule", detail: "booktabs top rule" }, + { label: "\\bottomrule", detail: "booktabs bottom rule" }, + { label: "\\centering", detail: "center content" }, + { label: "\\newpage", detail: "page break" }, + { label: "\\clearpage", detail: "flush and break" }, + { label: "\\vspace", snippet: "\\vspace{${}}", detail: "vertical space" }, + { label: "\\hspace", snippet: "\\hspace{${}}", detail: "horizontal space" }, ]; // Environment names suggested right after \begin{ or \end{. @@ -59,28 +112,70 @@ const environments = [ "table", "tabular", "center", + "flushleft", + "flushright", "quote", + "quotation", "verbatim", "equation", + "equation*", "align", + "align*", "gather", + "multline", "matrix", "bmatrix", "pmatrix", "cases", "frame", "block", + "columns", "theorem", + "lemma", "proof", + "minipage", +]; + +// Common packages suggested after \usepackage{. +const packages = [ + "amsmath", + "amssymb", + "amsthm", + "graphicx", + "geometry", + "hyperref", + "xcolor", + "babel", + "inputenc", + "fontenc", + "booktabs", + "array", + "enumitem", + "fancyhdr", + "setspace", + "microtype", + "lmodern", + "listings", + "tikz", + "pgfplots", + "caption", + "subcaption", + "float", + "multicol", + "titlesec", + "natbib", + "biblatex", + "siunitx", ]; const commandOptions: Completion[] = commands.map((command) => command.snippet ? snippetCompletion(command.snippet, { label: command.label, + detail: command.detail, type: "keyword", }) - : { label: command.label, type: "keyword" }, + : { label: command.label, detail: command.detail, type: "keyword" }, ); const environmentOptions: Completion[] = environments.map((name) => ({ @@ -88,17 +183,70 @@ const environmentOptions: Completion[] = environments.map((name) => ({ type: "type", })); +const packageOptions: Completion[] = packages.map((name) => ({ + label: name, + type: "class", +})); + +// collect things the document defines so we can suggest them back +function collect(context: CompletionContext, pattern: RegExp): Completion[] { + const text = context.state.doc.toString(); + const found = new Set(); + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + if (match[1]) for (const key of match[1].split(",")) found.add(key.trim()); + } + return [...found].map((label) => ({ label, type: "constant" })); +} + +// start of the value being typed inside braces, after the last { or , +function valueStart(text: string): number { + return Math.max(text.lastIndexOf("{"), text.lastIndexOf(",")) + 1; +} + function latexCompletions(context: CompletionContext): CompletionResult | null { - // inside \begin{...} or \end{...}, complete the environment name - const inEnvironment = context.matchBefore(/\\(?:begin|end)\{[a-zA-Z*]*$/); - if (inEnvironment) { - const braceAt = inEnvironment.text.indexOf("{"); + // environment names inside \begin{...} or \end{...} + const environment = context.matchBefore(/\\(?:begin|end)\{[a-zA-Z*]*$/); + if (environment) { return { - from: inEnvironment.from + braceAt + 1, + from: environment.from + environment.text.indexOf("{") + 1, options: environmentOptions, }; } + // package names inside \usepackage[...]{...} + const usepackage = context.matchBefore( + /\\usepackage(?:\[[^\]]*\])?\{[a-zA-Z0-9, -]*$/, + ); + if (usepackage) { + return { + from: usepackage.from + valueStart(usepackage.text), + options: packageOptions, + }; + } + + // labels defined in the document, inside a \ref-style command + const reference = context.matchBefore( + /\\(?:ref|eqref|pageref|autoref|cref|Cref)\{[^}]*$/, + ); + if (reference) { + return { + from: reference.from + reference.text.indexOf("{") + 1, + options: collect(context, /\\label\{([^}]+)\}/g), + }; + } + + // citation keys, taken from \bibitem entries in the document + const citation = context.matchBefore( + /\\(?:cite|citep|citet|parencite|textcite)\{[^}]*$/, + ); + if (citation) { + return { + from: citation.from + valueStart(citation.text), + options: collect(context, /\\bibitem(?:\[[^\]]*\])?\{([^}]+)\}/g), + }; + } + // a backslash followed by letters: complete the command const command = context.matchBefore(/\\[a-zA-Z]*/); if (command && (command.from < command.to || context.explicit)) {