feat: Notion-like rich text editor with TipTap, 4 note types, slash commands & bubble menu
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m33s

This commit is contained in:
2026-05-01 01:11:03 +02:00
parent 7053e242d2
commit 1345403a31
31 changed files with 3222 additions and 150 deletions

View File

@@ -1,7 +1,7 @@
'use client'
import { useState, useEffect, useRef, useCallback, useTransition } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
@@ -14,6 +14,8 @@ import { LabelBadge } from '@/components/label-badge'
import { EditorConnectionsSection } from '@/components/editor-connections-section'
import { FusionModal } from '@/components/fusion-modal'
import { ComparisonModal } from '@/components/comparison-modal'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { useLanguage } from '@/lib/i18n'
import { cn } from '@/lib/utils'
import {
@@ -35,7 +37,6 @@ import {
X,
Plus,
CheckSquare,
FileText,
Eye,
Sparkles,
Loader2,
@@ -83,7 +84,7 @@ function getDateLocale(language: string) {
/** Save content via REST API (not Server Action) to avoid Next.js implicit router re-renders */
async function saveInline(
id: string,
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean }
data: { title?: string | null; content?: string; checkItems?: CheckItem[]; isMarkdown?: boolean; type?: NoteType }
) {
await fetch(`/api/notes/${id}`, {
method: 'PUT',
@@ -127,7 +128,8 @@ export function NoteInlineEditor({
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content || '')
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState(
defaultPreviewMode && (note.isMarkdown || false)
)
@@ -157,31 +159,32 @@ export function NoteInlineEditor({
const fileInputRef = useRef<HTMLInputElement>(null)
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const pendingRef = useRef({ title, content, checkItems, isMarkdown })
const pendingRef = useRef({ title, content, checkItems, isMarkdown, noteType })
const noteIdRef = useRef(note.id)
// Title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
content: note.type === 'text' ? content : '',
enabled: note.type === 'text' && !title
content: noteType !== 'checklist' ? content : '',
enabled: noteType !== 'checklist' && !title
})
// Keep pending ref in sync for unmount save
useEffect(() => {
pendingRef.current = { title, content, checkItems, isMarkdown }
}, [title, content, checkItems, isMarkdown])
pendingRef.current = { title, content, checkItems, isMarkdown, noteType }
}, [title, content, checkItems, isMarkdown, noteType])
// ── Sync when selected note switches ─────────────────────────────────────
useEffect(() => {
// Flush unsaved changes for the PREVIOUS note before switching
if (isDirty && noteIdRef.current !== note.id) {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {})
}
@@ -189,8 +192,8 @@ export function NoteInlineEditor({
setTitle(note.title || '')
setContent(note.content || '')
setCheckItems(note.checkItems || [])
setIsMarkdown(note.isMarkdown || false)
setShowMarkdownPreview(defaultPreviewMode && (note.isMarkdown || false))
setNoteType(note.type)
setShowMarkdownPreview(defaultPreviewMode && (note.type === 'markdown'))
setIsDirty(false)
setDismissedTitleSuggestions(false)
clearTimeout(saveTimerRef.current)
@@ -202,14 +205,15 @@ export function NoteInlineEditor({
setIsDirty(true)
clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(async () => {
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
setIsSaving(true)
try {
await saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
})
setIsDirty(false)
} catch {
@@ -218,18 +222,19 @@ export function NoteInlineEditor({
setIsSaving(false)
}
}, 1500)
}, [note.type])
}, [noteType])
// Flush on unmount
useEffect(() => {
return () => {
clearTimeout(saveTimerRef.current)
const { title: t, content: c, checkItems: ci, isMarkdown: im } = pendingRef.current
const { title: t, content: c, checkItems: ci, isMarkdown: im, noteType: nt } = pendingRef.current
saveInline(noteIdRef.current, {
title: t.trim() || null,
content: c,
checkItems: note.type === 'checklist' ? ci : undefined,
isMarkdown: im,
checkItems: nt === 'checklist' ? ci : undefined,
type: nt,
isMarkdown: nt === 'markdown',
}).catch(() => {})
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -237,9 +242,9 @@ export function NoteInlineEditor({
// ── Auto-tagging ──────────────────────────────────────────────────────────
const { suggestions, isAnalyzing } = useAutoTagging({
content: note.type === 'text' ? content : '',
content: noteType !== 'checklist' ? content : '',
notebookId: note.notebookId,
enabled: note.type === 'text' && autoLabelingEnabled,
enabled: noteType !== 'checklist' && autoLabelingEnabled,
})
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(
@@ -288,7 +293,7 @@ export function NoteInlineEditor({
? [...new Set(fusionNotes.flatMap(n => n.labels || []))]
: fusionNotes[0].labels || [],
color: fusionNotes[0].color,
type: 'text',
type: 'markdown',
isMarkdown: true,
autoGenerated: true,
aiProvider: 'fusion',
@@ -488,20 +493,17 @@ export function NoteInlineEditor({
<LinkIcon className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => {
const nextIsMarkdown = !isMarkdown
setIsMarkdown(nextIsMarkdown)
onChange?.(note.id, { isMarkdown: nextIsMarkdown })
if (!nextIsMarkdown) setShowMarkdownPreview(false)
scheduleSave()
<NoteTypeSelector
value={noteType}
onChange={(newType) => {
setNoteType(newType)
if (newType !== 'markdown') setShowMarkdownPreview(false)
onChange?.(note.id, { isMarkdown: newType === 'markdown' })
}}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
compact
/>
{isMarkdown && (
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? (t('notes.edit')) : (t('notes.preview'))}>
@@ -509,7 +511,7 @@ export function NoteInlineEditor({
</Button>
)}
{note.type === 'text' && aiAssistantEnabled && (
{noteType !== 'checklist' && aiAssistantEnabled && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-colors', aiOpen && 'bg-primary/10 text-primary')}
onClick={() => setAiOpen(!aiOpen)}
@@ -743,7 +745,13 @@ export function NoteInlineEditor({
{/* ── Text / Checklist content ───────────────────────────────────── */}
<div className="mt-4 flex flex-1 flex-col">
{note.type === 'text' ? (
{noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[200px]"
/>
) : noteType === 'text' || noteType === 'markdown' ? (
<div className="flex flex-1 flex-col">
{showMarkdownPreview && isMarkdown ? (
<div className="prose prose-sm dark:prose-invert max-w-none flex-1 rounded-lg border border-border/40 bg-muted/20 p-4">