feat: integrate Milkdown editor for note-taking functionality

- Added Milkdown dependencies: @milkdown/kit, @milkdown/react, @milkdown/theme-nord.
- Implemented MilkdownEditor component for rich text editing in notes.
- Updated /notes/$noteId route to include editable title and auto-saving functionality.
- Enhanced UI with loading states, saving indicators, and delete confirmation dialog.
- Applied Milkdown-specific CSS overrides for consistent theming and styling.
- Improved note update logic with debounced saving and cleanup on unmount.
This commit is contained in:
Roberto Musso
2026-02-22 22:47:05 +01:00
parent 7860ca6ad1
commit 2308158976
9 changed files with 3016 additions and 34 deletions

View File

@@ -69,7 +69,7 @@ export function AppShell({ children }: AppShellProps) {
className="text-[9px] text-muted-foreground/30 tracking-widest uppercase font-medium"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
keep scrolling up for AI
scrolling up for Adiuva
</span>
</div>
</div>

View File

@@ -0,0 +1,47 @@
import { useRef } from 'react';
import { Editor, rootCtx, defaultValueCtx } from '@milkdown/kit/core';
import { commonmark } from '@milkdown/kit/preset/commonmark';
import { history } from '@milkdown/kit/plugin/history';
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener';
import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react';
import '@milkdown/kit/prose/view/style/prosemirror.css';
interface MilkdownEditorProps {
initialContent: string;
onChange: (markdown: string) => void;
}
function MilkdownInner({ initialContent, onChange }: MilkdownEditorProps) {
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEditor((root) =>
Editor.make()
.config((ctx) => {
ctx.set(rootCtx, root);
ctx.set(defaultValueCtx, initialContent);
})
.use(commonmark)
.use(history)
.use(listener)
.config((ctx) => {
ctx.get(listenerCtx).markdownUpdated((_ctx, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) {
onChangeRef.current(markdown);
}
});
}),
[]
);
return <Milkdown />;
}
export function MilkdownEditor(props: MilkdownEditorProps) {
return (
<MilkdownProvider>
<MilkdownInner {...props} />
</MilkdownProvider>
);
}

View File

@@ -140,3 +140,142 @@ body {
display: flex;
overflow: hidden;
}
/* Milkdown editor overrides */
[data-milkdown-root] {
display: flex;
flex-direction: column;
height: 100%;
}
.milkdown {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
color: var(--foreground);
font-family: inherit;
line-height: 1.75;
}
.milkdown .editor {
flex: 1;
outline: none;
padding: 0;
overflow-y: auto;
word-break: break-word;
overflow-wrap: break-word;
}
.milkdown .editor > * + * {
margin-top: 0.75em;
}
.milkdown .editor h1 {
font-size: 1.875rem;
font-weight: 700;
line-height: 1.2;
color: var(--foreground);
}
.milkdown .editor h2 {
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
color: var(--foreground);
}
.milkdown .editor h3 {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.4;
color: var(--foreground);
}
.milkdown .editor h4 {
font-size: 1.125rem;
font-weight: 600;
color: var(--foreground);
}
.milkdown .editor h5,
.milkdown .editor h6 {
font-size: 1rem;
font-weight: 600;
color: var(--foreground);
}
.milkdown .editor p {
color: var(--foreground);
}
.milkdown .editor blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
color: var(--muted-foreground);
font-style: italic;
}
.milkdown .editor pre {
background: var(--muted);
color: var(--foreground);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875rem;
}
.milkdown .editor code {
background: var(--muted);
color: var(--foreground);
padding: 0.125rem 0.375rem;
border-radius: calc(var(--radius) - 4px);
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875em;
}
.milkdown .editor pre code {
background: transparent;
padding: 0;
border-radius: 0;
}
.milkdown .editor ul,
.milkdown .editor ol {
padding-left: 1.5rem;
color: var(--foreground);
}
.milkdown .editor ul {
list-style-type: disc;
}
.milkdown .editor ol {
list-style-type: decimal;
}
.milkdown .editor li + li {
margin-top: 0.25em;
}
.milkdown .editor a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 2px;
}
.milkdown .editor hr {
border: none;
border-top: 1px solid var(--border);
margin: 1.5em 0;
}
.milkdown .editor strong {
font-weight: 600;
}
.milkdown .editor em {
font-style: italic;
}

View File

@@ -1,7 +1,23 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { ArrowLeft } from 'lucide-react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { createFileRoute } from '@tanstack/react-router';
import { ArrowLeft, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { trpc } from '@/lib/trpc';
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
export const Route = createFileRoute('/notes/$noteId')({
component: NoteDetailPage,
@@ -9,26 +25,175 @@ export const Route = createFileRoute('/notes/$noteId')({
function NoteDetailPage() {
const { noteId } = Route.useParams();
const navigate = useNavigate();
const { data: note } = trpc.notes.get.useQuery({ id: noteId });
const utils = trpc.useUtils();
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Store the latest markdown so we can flush it on back navigation
const pendingContentRef = useRef<string | null>(null);
const updateNote = trpc.notes.update.useMutation({
onSuccess: () => {
void utils.notes.get.invalidate({ id: noteId });
void utils.notes.list.invalidate();
},
onSettled: () => {
setIsSaving(false);
},
});
const deleteNote = trpc.notes.delete.useMutation({
onSuccess: () => {
void utils.notes.list.invalidate();
window.history.back();
},
});
// Sync title from server data on initial load
useEffect(() => {
if (note) {
setTitle(note.title);
}
}, [note]);
// Cleanup debounce timer on unmount
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, []);
const handleContentChange = useCallback(
(markdown: string) => {
setIsSaving(true);
pendingContentRef.current = markdown;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
pendingContentRef.current = null;
updateNote.mutate({ id: noteId, content: markdown });
}, 2000);
},
[noteId, updateNote]
);
const handleTitleBlur = () => {
const trimmed = title.trim();
if (trimmed && note && trimmed !== note.title) {
setIsSaving(true);
updateNote.mutate({ id: noteId, title: trimmed });
}
};
const handleTitleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
}
};
const handleBack = () => {
// Flush any pending debounced save immediately before navigating
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
if (pendingContentRef.current !== null) {
updateNote.mutate({ id: noteId, content: pendingContentRef.current });
pendingContentRef.current = null;
}
window.history.back();
};
const handleDelete = () => {
// Cancel any pending save before deleting
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
pendingContentRef.current = null;
deleteNote.mutate({ id: noteId });
};
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading note...</p>
</div>
);
}
if (!note) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Note not found.</p>
</div>
);
}
return (
<div className="flex flex-col gap-4 p-6">
<div className="flex items-center gap-2">
<div className="flex h-full min-h-0 pe-8 flex-col">
{/* Header */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Button
variant="ghost"
size="icon"
onClick={() => void navigate({ to: '/projects' })}
onClick={handleBack}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<h1 className="text-2xl font-semibold">
{note?.title ?? 'Loading...'}
</h1>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={handleTitleBlur}
onKeyDown={handleTitleKeyDown}
className="border-0 shadow-none text-2xl font-semibold focus-visible:ring-0 px-0 h-auto"
placeholder="Untitled Note"
/>
{isSaving && <Badge variant="secondary">Saving...</Badge>}
<span className="text-muted-foreground text-xs whitespace-nowrap">
Last edited {format(new Date(note.updatedAt), 'MMM d, yyyy · h:mm a')}
</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={deleteNote.isPending}
>
<Trash2 /> Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete note</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{title || 'Untitled Note'}&quot;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleDelete}>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{/* Editor */}
<div className="flex-1 min-h-0 px-4 py-4 flex flex-col">
<MilkdownEditor
key={noteId}
initialContent={note.content}
onChange={handleContentChange}
/>
</div>
<p className="text-muted-foreground text-sm">
Note editor will be implemented in US-016 (Milkdown).
</p>
</div>
);
}