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, useRef, useEffect } from 'react'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata } from '@/lib/types'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType } from '@/lib/types'
import {
Dialog,
DialogContent,
@@ -23,7 +23,9 @@ import {
DropdownMenuSubTrigger,
DropdownMenuSubContent,
} from '@/components/ui/dropdown-menu'
import { X, Plus, Palette, Image as ImageIcon, Bell, FileText, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { X, Plus, Palette, Image as ImageIcon, Bell, Eye, Link as LinkIcon, Sparkles, Maximize2, Copy, Wand2, LogOut } from 'lucide-react'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { cn } from '@/lib/utils'
@@ -85,8 +87,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [isMarkdown, setIsMarkdown] = useState(note.isMarkdown || false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.isMarkdown || false)
const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
const fileInputRef = useRef<HTMLInputElement>(null)
// Update context notebookId when note changes
@@ -96,9 +99,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// Auto-tagging hook - use local state for live suggestions as user types
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
})
// Reminder state
@@ -466,7 +469,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
// Set the transformed markdown content and enable markdown mode
setContent(data.transformedText)
setIsMarkdown(true)
setNoteType('markdown')
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
@@ -523,14 +526,15 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
try {
await updateNote(note.id, {
title: title.trim() || null,
content: note.type === 'text' ? content : '',
checkItems: note.type === 'checklist' ? checkItems : null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown,
isMarkdown: noteType === 'markdown',
type: noteType,
size,
})
@@ -588,12 +592,12 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
content: content,
color: color,
type: note.type,
checkItems: checkItems,
labels: labels,
images: images,
links: links,
isMarkdown: isMarkdown,
isMarkdown: noteType === 'markdown',
type: noteType,
size: size,
})
toast.success(t('notes.copySuccess'))
@@ -697,7 +701,13 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
)}
{/* Content or Checklist */}
{note.type === 'text' ? (
{noteType === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[200px]"
/>
) : noteType === 'text' || noteType === 'markdown' ? (
<div className="space-y-2">
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
@@ -847,18 +857,9 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
<LinkIcon className="h-4 w-4" />
</Button>
{/* Markdown toggle */}
{note.type === 'text' && (
<Button variant="ghost" size="icon"
className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}
title="Markdown">
<FileText className="h-4 w-4" />
</Button>
)}
<NoteTypeSelector value={noteType} onChange={(newType) => { setNoteType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{/* Markdown preview toggle */}
{isMarkdown && (
{noteType === 'markdown' && (
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}
title={showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
@@ -867,7 +868,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
)}
{/* AI Copilot */}
{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)} title="Assistant IA">