From 7219b695194676d4fa70d08146016a548f72004d Mon Sep 17 00:00:00 2001 From: Leonardo Salas Date: Wed, 17 Jun 2026 18:11:01 -0600 Subject: [PATCH 1/2] feat: formatting toolbar, keyboard shortcuts, and PDF zoom Adds a formatting toolbar above the editor with bold, italic, underline, section and subsection, bulleted and numbered lists, and inline math. Each one wraps the selection or drops the cursor in the right place. Save (Mod+S) and compile (Mod+Enter) now have keyboard shortcuts that work on macOS, Windows, and Linux. The PDF preview gains zoom controls. --- src/app/editor/[id]/page.tsx | 26 ++++++- src/components/editor/FormatBar.tsx | 102 ++++++++++++++++++++++++++ src/components/editor/PreviewPane.tsx | 43 ++++++++++- 3 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 src/components/editor/FormatBar.tsx diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index 960005e..eda3b79 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) { @@ -232,12 +252,14 @@ export default function EditorPage({ params }: { params: { id: string } }) { +
{ editorViewRef.current = view; + setEditorView(view); }} />
diff --git a/src/components/editor/FormatBar.tsx b/src/components/editor/FormatBar.tsx new file mode 100644 index 0000000..9cd94cd --- /dev/null +++ b/src/components/editor/FormatBar.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { + Bold, + Heading1, + Heading2, + Italic, + List, + ListOrdered, + Sigma, + Underline, + type LucideIcon, +} from "lucide-react"; +import type { EditorView } from "@codemirror/view"; + +// wrap the current selection (or drop the cursor between the braces if nothing +// is selected) +function wrap(view: EditorView, before: string, after: string) { + const { from, to } = view.state.selection.main; + const selected = view.state.sliceDoc(from, to); + view.dispatch({ + changes: { from, to, insert: before + selected + after }, + selection: { anchor: from + before.length + selected.length }, + }); + view.focus(); +} + +// drop a block of text where the cursor is, e.g. a list environment +function insertBlock(view: EditorView, text: string) { + const { from, to } = view.state.selection.main; + view.dispatch({ + changes: { from, to, insert: text }, + selection: { anchor: from + text.length }, + }); + view.focus(); +} + +const ITEMIZE = "\\begin{itemize}\n \\item \n\\end{itemize}\n"; +const ENUMERATE = "\\begin{enumerate}\n \\item \n\\end{enumerate}\n"; + +interface Action { + icon: LucideIcon; + title: string; + run: (view: EditorView) => 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" && ( +
+ + + +
+ )}
); } From 390b3424d85391d895a8b853b03a2fc1baddd0a3 Mon Sep 17 00:00:00 2001 From: Leonardo Salas Date: Wed, 17 Jun 2026 18:15:23 -0600 Subject: [PATCH 2/2] feat: clickable compile errors and richer autocomplete The compile log now lists the errors it found and, when an error names a line, clicking it jumps the editor there. Autocomplete grows from a handful of commands to an everyday set with short descriptions, so typing "\geo" finds "\geometry". It also reads the document to suggest package names after \usepackage, your own labels after \ref, and bib keys after \cite. --- src/app/editor/[id]/page.tsx | 10 ++ src/components/editor/CompileLog.tsx | 70 +++++++- src/components/editor/latex.ts | 232 ++++++++++++++++++++++----- 3 files changed, 266 insertions(+), 46 deletions(-) diff --git a/src/app/editor/[id]/page.tsx b/src/app/editor/[id]/page.tsx index eda3b79..608644f 100644 --- a/src/app/editor/[id]/page.tsx +++ b/src/app/editor/[id]/page.tsx @@ -185,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; @@ -268,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) => ( +
  • + +
  • + ))} +
+ )} +
   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)) {