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:
parent
a065388bbf
commit
f5bec85336
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
• 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"
|
||||
>
|
||||
“ 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} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue