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:
@@ -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>
|
||||
|
||||
47
src/renderer/components/notes/MilkdownEditor.tsx
Normal file
47
src/renderer/components/notes/MilkdownEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 "{title || 'Untitled Note'}"? 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user