Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions src/app/editor/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<EditorView | null>(null);

const { compile, status, log, durationMs, pdfVersion } = useCompile(id);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -232,12 +261,14 @@ export default function EditorPage({ params }: { params: { id: string } }) {
<SplitPane
left={
<div className="flex h-full flex-col">
<FormatBar view={editorView} />
<div className="min-h-0 flex-1">
<EditorPane
value={source}
onChange={handleChange}
onReady={(view) => {
editorViewRef.current = view;
setEditorView(view);
}}
/>
</div>
Expand All @@ -246,6 +277,7 @@ export default function EditorPage({ params }: { params: { id: string } }) {
log={log}
status={status}
onClose={() => setShowLog(false)}
onGoToLine={goToLine}
/>
)}
</div>
Expand Down
70 changes: 66 additions & 4 deletions src/components/editor/CompileLog.tsx
Original file line number Diff line number Diff line change
@@ -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.<n>" 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<HTMLPreElement>(null);
const errors = parseErrors(log);

// follow the output as it streams in
useEffect(() => {
Expand All @@ -20,10 +57,14 @@ export function CompileLog({ log, status, onClose }: CompileLogProps) {
}, [log]);

return (
<div className="flex h-48 flex-col border-t border-border bg-surface">
<div className="flex h-56 flex-col border-t border-border bg-surface">
<div className="flex items-center justify-between border-b border-border px-3 py-1.5">
<span className="text-xs font-medium text-text-muted">
{status === "running" ? "Compiling..." : "Compile log"}
{status === "running"
? "Compiling..."
: errors.length > 0
? `${errors.length} error${errors.length === 1 ? "" : "s"}`
: "Compile log"}
</span>
<button
onClick={onClose}
Expand All @@ -33,6 +74,27 @@ export function CompileLog({ log, status, onClose }: CompileLogProps) {
<X className="h-3.5 w-3.5" />
</button>
</div>

{errors.length > 0 && (
<ul className="max-h-24 shrink-0 overflow-auto border-b border-border">
{errors.map((error, index) => (
<li key={index}>
<button
onClick={() => error.line != null && onGoToLine(error.line)}
disabled={error.line == null}
className="flex w-full items-start gap-2 px-3 py-1.5 text-left text-xs transition hover:bg-surface-2 disabled:cursor-default disabled:hover:bg-transparent"
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-danger" />
<span className="flex-1 text-text">{error.message}</span>
{error.line != null && (
<span className="shrink-0 text-text-muted">line {error.line}</span>
)}
</button>
</li>
))}
</ul>
)}

<pre
ref={bodyRef}
className="flex-1 overflow-auto whitespace-pre-wrap px-3 py-2 font-mono text-xs leading-relaxed text-text-muted"
Expand Down
102 changes: 102 additions & 0 deletions src/components/editor/FormatBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-1 border-b border-border bg-surface px-2 py-1">
{groups.map((group, index) => (
<div key={index} className="flex items-center gap-0.5">
{index > 0 && <span className="mx-1 h-4 w-px bg-border" />}
{group.map((action) => (
<button
key={action.title}
title={action.title}
aria-label={action.title}
disabled={!view}
onClick={() => view && action.run(view)}
className="flex h-7 w-7 items-center justify-center rounded-md text-text-muted transition hover:bg-surface-2 hover:text-text disabled:opacity-40"
>
<action.icon className="h-4 w-4" />
</button>
))}
</div>
))}
</div>
);
}
43 changes: 41 additions & 2 deletions src/components/editor/PreviewPane.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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({
Expand All @@ -23,6 +28,7 @@ export function PreviewPane({
const pagesRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<RenderState>("blank");
const [zoom, setZoom] = useState(1);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -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++) {
Expand Down Expand Up @@ -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 = (() => {
Expand All @@ -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 (
<div ref={scrollRef} className="relative h-full overflow-auto bg-surface-2">
<div ref={pagesRef} className="p-6" />
Expand All @@ -126,6 +137,34 @@ export function PreviewPane({
)}
</div>
)}

{state === "ready" && (
<div className="glass sticky bottom-4 left-1/2 flex w-fit -translate-x-1/2 items-center gap-1 rounded-full border border-border px-1 py-1 shadow-lift">
<button
onClick={() => changeZoom(-ZOOM_STEP)}
disabled={zoom <= ZOOM_MIN}
className="flex h-7 w-7 items-center justify-center rounded-full text-text-muted transition hover:bg-surface-2 hover:text-text disabled:opacity-40"
aria-label="Zoom out"
>
<Minus className="h-4 w-4" />
</button>
<button
onClick={() => setZoom(1)}
className="min-w-12 px-1 text-xs font-medium tabular-nums text-text-muted transition hover:text-text"
aria-label="Reset zoom"
>
{Math.round(zoom * 100)}%
</button>
<button
onClick={() => changeZoom(ZOOM_STEP)}
disabled={zoom >= ZOOM_MAX}
className="flex h-7 w-7 items-center justify-center rounded-full text-text-muted transition hover:bg-surface-2 hover:text-text disabled:opacity-40"
aria-label="Zoom in"
>
<Plus className="h-4 w-4" />
</button>
</div>
)}
</div>
);
}
Loading
Loading