Skip to content

Commit 377cae6

Browse files
rjwaltersclaude
andauthored
feat: add Monaco markdown editor with live preview and auto-save (#22)
Integrate @monaco-editor/react as the primary document editing surface with: - Lazy-loaded Monaco editor with markdown syntax highlighting - Split/edit/preview view modes with react-markdown rendering - Debounced auto-save hook (2s delay) that persists to document API - Dark/light theme sync via app theme context - Responsive layout filling available viewport space - @tailwindcss/typography for styled markdown preview Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3e70745 commit 377cae6

8 files changed

Lines changed: 1028 additions & 0 deletions

File tree

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,18 @@
2222
"worktree:return": "./.loom/scripts/worktree-return.sh"
2323
},
2424
"dependencies": {
25+
"@monaco-editor/react": "^4.7.0",
2526
"@radix-ui/react-dropdown-menu": "^2.1.4",
2627
"@radix-ui/react-slot": "^1.1.1",
2728
"@radix-ui/react-switch": "^1.1.2",
2829
"@radix-ui/react-toast": "^1.2.4",
30+
"@tailwindcss/typography": "^0.5.19",
2931
"class-variance-authority": "^0.7.1",
3032
"clsx": "^2.1.1",
3133
"lucide-react": "^0.468.0",
3234
"react": "^19.0.0",
3335
"react-dom": "^19.0.0",
36+
"react-markdown": "^10.1.0",
3437
"react-router-dom": "^7.1.1",
3538
"tailwind-merge": "^2.6.0"
3639
},

pnpm-lock.yaml

Lines changed: 758 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
22
import { Layout } from "@/components/Layout";
33
import { useAuth } from "@/hooks/use-auth";
44
import { DashboardPage } from "@/pages/DashboardPage";
5+
import { DocumentEditPage } from "@/pages/DocumentEditPage";
56
import { HomePage } from "@/pages/HomePage";
67
import { LoginPage } from "@/pages/LoginPage";
78
import { ProfilePage } from "@/pages/ProfilePage";
@@ -73,6 +74,14 @@ export default function App() {
7374
</ProtectedRoute>
7475
}
7576
/>
77+
<Route
78+
path="/projects/:projectId/documents/:documentId/edit"
79+
element={
80+
<ProtectedRoute>
81+
<DocumentEditPage />
82+
</ProtectedRoute>
83+
}
84+
/>
7685
</Routes>
7786
</Layout>
7887
);

src/components/MarkdownEditor.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { lazy, Suspense } from "react";
2+
import { useTheme } from "@/hooks/use-theme";
3+
4+
const Editor = lazy(() => import("@monaco-editor/react").then((mod) => ({ default: mod.default })));
5+
6+
interface MarkdownEditorProps {
7+
value: string;
8+
onChange: (value: string) => void;
9+
}
10+
11+
export function MarkdownEditor({ value, onChange }: MarkdownEditorProps) {
12+
const { resolvedTheme } = useTheme();
13+
14+
return (
15+
<Suspense
16+
fallback={
17+
<div className="flex h-full items-center justify-center bg-background">
18+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
19+
</div>
20+
}
21+
>
22+
<Editor
23+
language="markdown"
24+
theme={resolvedTheme === "dark" ? "vs-dark" : "vs"}
25+
value={value}
26+
onChange={(v) => onChange(v ?? "")}
27+
options={{
28+
wordWrap: "on",
29+
minimap: { enabled: false },
30+
fontSize: 14,
31+
lineNumbers: "on",
32+
scrollBeyondLastLine: false,
33+
automaticLayout: true,
34+
padding: { top: 12 },
35+
}}
36+
loading={
37+
<div className="flex h-full items-center justify-center bg-background">
38+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
39+
</div>
40+
}
41+
/>
42+
</Suspense>
43+
);
44+
}

src/components/MarkdownPreview.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Markdown from "react-markdown";
2+
3+
interface MarkdownPreviewProps {
4+
content: string;
5+
}
6+
7+
export function MarkdownPreview({ content }: MarkdownPreviewProps) {
8+
return (
9+
<div className="prose prose-sm dark:prose-invert max-w-none overflow-auto p-4">
10+
<Markdown>{content}</Markdown>
11+
</div>
12+
);
13+
}

src/hooks/use-auto-save.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { useCallback, useEffect, useRef } from "react";
2+
3+
export function useAutoSave(
4+
content: string,
5+
onSave: (content: string) => Promise<void>,
6+
delayMs = 2000,
7+
) {
8+
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
9+
const lastSavedRef = useRef(content);
10+
const isSavingRef = useRef(false);
11+
const contentRef = useRef(content);
12+
const saveRef = useRef(onSave);
13+
14+
contentRef.current = content;
15+
saveRef.current = onSave;
16+
17+
const save = useCallback(async (value: string) => {
18+
if (isSavingRef.current || value === lastSavedRef.current) return;
19+
isSavingRef.current = true;
20+
try {
21+
await saveRef.current(value);
22+
lastSavedRef.current = value;
23+
} finally {
24+
isSavingRef.current = false;
25+
}
26+
}, []);
27+
28+
useEffect(() => {
29+
if (content === lastSavedRef.current) return;
30+
31+
if (timeoutRef.current) {
32+
clearTimeout(timeoutRef.current);
33+
}
34+
35+
timeoutRef.current = setTimeout(() => {
36+
save(content);
37+
}, delayMs);
38+
39+
return () => {
40+
if (timeoutRef.current) {
41+
clearTimeout(timeoutRef.current);
42+
}
43+
};
44+
}, [content, delayMs, save]);
45+
46+
// Flush on unmount
47+
useEffect(() => {
48+
return () => {
49+
if (timeoutRef.current) {
50+
clearTimeout(timeoutRef.current);
51+
}
52+
const current = contentRef.current;
53+
if (lastSavedRef.current !== current) {
54+
save(current);
55+
}
56+
};
57+
}, [save]);
58+
}

src/pages/DocumentEditPage.tsx

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { useCallback, useEffect, useState } from "react";
2+
import { Link, useParams } from "react-router-dom";
3+
import { MarkdownEditor } from "@/components/MarkdownEditor";
4+
import { MarkdownPreview } from "@/components/MarkdownPreview";
5+
import { Button } from "@/components/ui/button";
6+
import { useAutoSave } from "@/hooks/use-auto-save";
7+
8+
type ViewMode = "edit" | "preview" | "split";
9+
10+
export function DocumentEditPage() {
11+
const { projectId, documentId } = useParams<{
12+
projectId: string;
13+
documentId: string;
14+
}>();
15+
const [content, setContent] = useState("");
16+
const [isLoading, setIsLoading] = useState(true);
17+
const [error, setError] = useState<string | null>(null);
18+
const [viewMode, setViewMode] = useState<ViewMode>("split");
19+
const [saveStatus, setSaveStatus] = useState<"saved" | "saving" | "unsaved">("saved");
20+
21+
useEffect(() => {
22+
async function loadDocument() {
23+
if (!projectId || !documentId) return;
24+
try {
25+
const response = await fetch(`/api/projects/${projectId}/documents/${documentId}`, {
26+
credentials: "include",
27+
});
28+
if (!response.ok) throw new Error("Failed to load document");
29+
const data = await response.json();
30+
setContent(data.content ?? "");
31+
} catch (err) {
32+
setError(err instanceof Error ? err.message : "Failed to load document");
33+
} finally {
34+
setIsLoading(false);
35+
}
36+
}
37+
loadDocument();
38+
}, [projectId, documentId]);
39+
40+
const handleSave = useCallback(
41+
async (value: string) => {
42+
if (!projectId || !documentId) return;
43+
setSaveStatus("saving");
44+
try {
45+
const response = await fetch(`/api/projects/${projectId}/documents/${documentId}`, {
46+
method: "PUT",
47+
headers: { "Content-Type": "application/json" },
48+
credentials: "include",
49+
body: JSON.stringify({ content: value }),
50+
});
51+
if (!response.ok) throw new Error("Failed to save document");
52+
setSaveStatus("saved");
53+
} catch {
54+
setSaveStatus("unsaved");
55+
}
56+
},
57+
[projectId, documentId],
58+
);
59+
60+
useAutoSave(content, handleSave);
61+
62+
const handleChange = (value: string) => {
63+
setContent(value);
64+
setSaveStatus("unsaved");
65+
};
66+
67+
if (isLoading) {
68+
return (
69+
<div className="flex h-[calc(100vh-3.5rem)] items-center justify-center">
70+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
71+
</div>
72+
);
73+
}
74+
75+
if (error) {
76+
return (
77+
<div className="flex h-[calc(100vh-3.5rem)] flex-col items-center justify-center gap-4">
78+
<p className="text-destructive">{error}</p>
79+
<Button asChild variant="outline">
80+
<Link to={`/projects/${projectId}`}>Back to Project</Link>
81+
</Button>
82+
</div>
83+
);
84+
}
85+
86+
return (
87+
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
88+
{/* Toolbar */}
89+
<div className="flex items-center justify-between border-b px-4 py-2">
90+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
91+
<Link to={`/projects/${projectId}`} className="hover:text-foreground">
92+
Project
93+
</Link>
94+
<span>/</span>
95+
<span>Edit Document</span>
96+
<span className="ml-2 text-xs">
97+
{saveStatus === "saving" && "(Saving...)"}
98+
{saveStatus === "saved" && "(Saved)"}
99+
{saveStatus === "unsaved" && "(Unsaved changes)"}
100+
</span>
101+
</div>
102+
<div className="flex items-center gap-1">
103+
<Button
104+
variant={viewMode === "edit" ? "default" : "ghost"}
105+
size="sm"
106+
onClick={() => setViewMode("edit")}
107+
>
108+
Edit
109+
</Button>
110+
<Button
111+
variant={viewMode === "split" ? "default" : "ghost"}
112+
size="sm"
113+
onClick={() => setViewMode("split")}
114+
>
115+
Split
116+
</Button>
117+
<Button
118+
variant={viewMode === "preview" ? "default" : "ghost"}
119+
size="sm"
120+
onClick={() => setViewMode("preview")}
121+
>
122+
Preview
123+
</Button>
124+
</div>
125+
</div>
126+
127+
{/* Editor / Preview */}
128+
<div className="flex min-h-0 flex-1">
129+
{viewMode !== "preview" && (
130+
<div className={`min-h-0 ${viewMode === "split" ? "w-1/2 border-r" : "w-full"}`}>
131+
<MarkdownEditor value={content} onChange={handleChange} />
132+
</div>
133+
)}
134+
{viewMode !== "edit" && (
135+
<div className={`min-h-0 overflow-auto ${viewMode === "split" ? "w-1/2" : "w-full"}`}>
136+
<MarkdownPreview content={content} />
137+
</div>
138+
)}
139+
</div>
140+
</div>
141+
);
142+
}

src/styles/globals.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
@import "tailwindcss";
2+
@plugin "@tailwindcss/typography";
23

34
@theme {
45
--color-border: hsl(var(--border));

0 commit comments

Comments
 (0)