feat: upgrade to TipTap WYSIWYG rich text editor

Replace plain Markdown textarea with TipTap editor featuring:
- Toolbar with bold, italic, strike, code, headings, lists, tasks,
  blockquotes, code blocks, links, images, undo/redo
- Keyboard shortcuts (Ctrl+B, Ctrl+I, etc.)
- Task list with checkboxes
- Inline image embedding
- Code type notes still use plain textarea for monospace editing
- Note detail view now renders HTML content from TipTap

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 13:36:21 -07:00
parent a065388bbf
commit f5bec85336
5 changed files with 1252 additions and 51 deletions

960
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,19 +13,29 @@
},
"dependencies": {
"@prisma/client": "^6.19.2",
"@tiptap/extension-code-block-lowlight": "^3.19.0",
"@tiptap/extension-image": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/extension-task-item": "^3.19.0",
"@tiptap/extension-task-list": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"dompurify": "^3.2.0",
"lowlight": "^3.3.0",
"marked": "^15.0.0",
"nanoid": "^5.0.9",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"zustand": "^5.0.11",
"marked": "^15.0.0",
"dompurify": "^3.2.0"
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/dompurify": "^3",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/dompurify": "^3",
"postcss": "^8",
"prisma": "^6.19.2",
"tailwindcss": "^3.4.1",

View File

@ -11,3 +11,50 @@ body {
color: var(--foreground);
background: var(--background);
}
/* TipTap editor styles */
.tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
color: #475569;
pointer-events: none;
height: 0;
}
.tiptap ul[data-type="taskList"] {
list-style: none;
padding-left: 0;
}
.tiptap ul[data-type="taskList"] li {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.tiptap ul[data-type="taskList"] li label {
flex-shrink: 0;
margin-top: 0.2rem;
}
.tiptap ul[data-type="taskList"] li label input[type="checkbox"] {
accent-color: #f59e0b;
width: 1rem;
height: 1rem;
cursor: pointer;
}
.tiptap ul[data-type="taskList"] li div {
flex: 1;
}
.tiptap img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
}
.tiptap hr {
border-color: #334155;
margin: 1.5rem 0;
}

View File

@ -269,13 +269,10 @@ export default function NoteDetailPage() {
</code>
</pre>
) : (
<div className="prose prose-invert prose-sm max-w-none">
<div
className="whitespace-pre-wrap text-slate-300 leading-relaxed"
>
{note.content}
</div>
</div>
<div
className="prose prose-invert prose-sm max-w-none text-slate-300 leading-relaxed"
dangerouslySetInnerHTML={{ __html: note.content }}
/>
)}
</div>
)}

View File

@ -1,6 +1,13 @@
'use client';
import { useState } from 'react';
import { useCallback } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Image from '@tiptap/extension-image';
interface NoteEditorProps {
value: string;
@ -9,49 +16,237 @@ interface NoteEditorProps {
placeholder?: string;
}
export function NoteEditor({ value, onChange, type, placeholder }: NoteEditorProps) {
const [showPreview, setShowPreview] = useState(false);
function ToolbarButton({
active,
onClick,
children,
title,
}: {
active?: boolean;
onClick: () => void;
children: React.ReactNode;
title: string;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
className={`px-2 py-1 text-sm rounded transition-colors ${
active
? 'bg-amber-500/20 text-amber-400'
: 'text-slate-400 hover:text-white hover:bg-slate-700'
}`}
>
{children}
</button>
);
}
const isCode = type === 'CODE';
function RichEditor({ value, onChange, placeholder: placeholderText }: Omit<NoteEditorProps, 'type'>) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: { levels: [1, 2, 3] },
codeBlock: { HTMLAttributes: { class: 'bg-slate-800 rounded-lg p-3 font-mono text-sm' } },
code: { HTMLAttributes: { class: 'bg-slate-800 rounded px-1.5 py-0.5 font-mono text-sm text-amber-300' } },
blockquote: { HTMLAttributes: { class: 'border-l-2 border-amber-500/50 pl-4 text-slate-400' } },
}),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: 'text-blue-400 underline hover:text-blue-300 cursor-pointer' },
}),
Placeholder.configure({ placeholder: placeholderText || 'Start writing...' }),
TaskList,
TaskItem.configure({ nested: true }),
Image.configure({ inline: true }),
],
content: value || '',
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},
editorProps: {
attributes: {
class: 'prose prose-invert prose-sm max-w-none p-4 min-h-[300px] focus:outline-none',
},
},
});
const addLink = useCallback(() => {
if (!editor) return;
const url = window.prompt('URL');
if (url) {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
}
}, [editor]);
const addImage = useCallback(() => {
if (!editor) return;
const url = window.prompt('Image URL');
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}, [editor]);
if (!editor) return null;
return (
<div className="border border-slate-700 rounded-lg overflow-hidden">
<div className="flex border-b border-slate-700 bg-slate-800/50">
<button
type="button"
onClick={() => setShowPreview(false)}
className={`px-4 py-2 text-sm transition-colors ${
!showPreview ? 'text-amber-400 border-b-2 border-amber-400' : 'text-slate-400 hover:text-slate-300'
}`}
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 border-b border-slate-700 bg-slate-800/50">
<ToolbarButton
active={editor.isActive('bold')}
onClick={() => editor.chain().focus().toggleBold().run()}
title="Bold (Ctrl+B)"
>
Edit
</button>
<button
type="button"
onClick={() => setShowPreview(true)}
className={`px-4 py-2 text-sm transition-colors ${
showPreview ? 'text-amber-400 border-b-2 border-amber-400' : 'text-slate-400 hover:text-slate-300'
}`}
<strong>B</strong>
</ToolbarButton>
<ToolbarButton
active={editor.isActive('italic')}
onClick={() => editor.chain().focus().toggleItalic().run()}
title="Italic (Ctrl+I)"
>
Preview
</button>
<em>I</em>
</ToolbarButton>
<ToolbarButton
active={editor.isActive('strike')}
onClick={() => editor.chain().focus().toggleStrike().run()}
title="Strikethrough"
>
<s>S</s>
</ToolbarButton>
<ToolbarButton
active={editor.isActive('code')}
onClick={() => editor.chain().focus().toggleCode().run()}
title="Inline Code"
>
{'</>'}
</ToolbarButton>
<div className="w-px h-5 bg-slate-700 mx-1" />
<ToolbarButton
active={editor.isActive('heading', { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
title="Heading 1"
>
H1
</ToolbarButton>
<ToolbarButton
active={editor.isActive('heading', { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
title="Heading 2"
>
H2
</ToolbarButton>
<ToolbarButton
active={editor.isActive('heading', { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
title="Heading 3"
>
H3
</ToolbarButton>
<div className="w-px h-5 bg-slate-700 mx-1" />
<ToolbarButton
active={editor.isActive('bulletList')}
onClick={() => editor.chain().focus().toggleBulletList().run()}
title="Bullet List"
>
&bull; List
</ToolbarButton>
<ToolbarButton
active={editor.isActive('orderedList')}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
title="Numbered List"
>
1. List
</ToolbarButton>
<ToolbarButton
active={editor.isActive('taskList')}
onClick={() => editor.chain().focus().toggleTaskList().run()}
title="Task List"
>
Tasks
</ToolbarButton>
<div className="w-px h-5 bg-slate-700 mx-1" />
<ToolbarButton
active={editor.isActive('blockquote')}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
title="Blockquote"
>
&ldquo; Quote
</ToolbarButton>
<ToolbarButton
active={editor.isActive('codeBlock')}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
title="Code Block"
>
{'{ }'}
</ToolbarButton>
<div className="w-px h-5 bg-slate-700 mx-1" />
<ToolbarButton
active={editor.isActive('link')}
onClick={addLink}
title="Add Link"
>
Link
</ToolbarButton>
<ToolbarButton
onClick={addImage}
title="Add Image"
>
Img
</ToolbarButton>
<div className="w-px h-5 bg-slate-700 mx-1" />
<ToolbarButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
title="Undo"
>
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
title="Redo"
>
</ToolbarButton>
</div>
{showPreview ? (
<div
className="p-4 min-h-[300px] prose prose-invert prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: value || '<p class="text-slate-500">Nothing to preview</p>' }}
/>
) : (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Write your note in Markdown...'}
className={`w-full min-h-[300px] p-4 bg-transparent text-slate-200 placeholder-slate-600 resize-y focus:outline-none ${
isCode ? 'font-mono text-sm' : ''
}`}
/>
)}
{/* Editor */}
<EditorContent editor={editor} />
</div>
);
}
export function NoteEditor({ value, onChange, type, placeholder }: NoteEditorProps) {
const isCode = type === 'CODE';
if (isCode) {
return (
<div className="border border-slate-700 rounded-lg overflow-hidden">
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || 'Paste your code here...'}
className="w-full min-h-[300px] p-4 bg-transparent text-slate-200 placeholder-slate-600 resize-y focus:outline-none font-mono text-sm"
/>
</div>
);
}
return <RichEditor value={value} onChange={onChange} placeholder={placeholder} />;
}