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

@@ -16,13 +16,12 @@ import {
MoreVertical,
Undo2,
Redo2,
FileText,
Eye,
Link as LinkIcon
} from 'lucide-react'
import { createNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note } from '@/lib/types'
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note, NoteType } from '@/lib/types'
import { ContextualAIChat } from './contextual-ai-chat'
import { Maximize2, Minimize2, Sparkles, Loader2 } from 'lucide-react'
import { useNotebooks } from '@/context/notebooks-context'
@@ -41,6 +40,8 @@ import {
import { cn } from '@/lib/utils'
import { toast } from 'sonner'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { NoteTypeSelector } from '@/components/note-type-selector'
import { RichTextEditor } from '@/components/rich-text-editor'
import { MarkdownContent } from './markdown-content'
import { LabelSelector } from './label-selector'
import { LabelBadge } from './label-badge'
@@ -107,7 +108,7 @@ export function NoteInput({
useEffect(() => {
if (forceExpanded) setIsExpanded(true)
}, [forceExpanded])
const [type, setType] = useState<'text' | 'checklist'>('text')
const [type, setType] = useState<NoteType>('richtext')
const [isSubmitting, setIsSubmitting] = useState(false)
const [color, setColor] = useState<NoteColor>('default')
const [isArchived, setIsArchived] = useState(false)
@@ -126,8 +127,9 @@ export function NoteInput({
const [checkItems, setCheckItems] = useState<CheckItem[]>([])
const [images, setImages] = useState<string[]>([])
const [links, setLinks] = useState<LinkMetadata[]>([])
const [isMarkdown, setIsMarkdown] = useState(false)
const [showMarkdownPreview, setShowMarkdownPreview] = useState(false)
const isMarkdown = type === 'markdown'
const isRichText = type === 'richtext'
// Auto-resize textarea based on content
useEffect(() => {
@@ -145,14 +147,14 @@ export function NoteInput({
// Auto-tagging hook
const { suggestions, isAnalyzing } = useAutoTagging({
content: type === 'text' ? fullContentForAI : '',
enabled: type === 'text' && isExpanded && autoLabelingEnabled,
content: type !== 'checklist' ? fullContentForAI : '',
enabled: type !== 'checklist' && isExpanded && autoLabelingEnabled,
notebookId: currentNotebookId
})
// Title suggestions
const titleSuggestionsEnabled = type === 'text' && isExpanded && !title
const titleSuggestionsContent = type === 'text' ? fullContentForAI : ''
const titleSuggestionsEnabled = (type === 'text' || type === 'markdown' || type === 'richtext') && isExpanded && !title
const titleSuggestionsContent = type !== 'checklist' ? fullContentForAI : ''
// Title suggestions hook
const { suggestions: titleSuggestions, isAnalyzing: isAnalyzingTitles } = useTitleSuggestions({
@@ -357,9 +359,8 @@ export function NoteInput({
const data = await response.json()
if (!response.ok) throw new Error(data.error || 'Failed to transform')
// Set the transformed markdown content and enable markdown mode
setContent(data.transformedText)
setIsMarkdown(true)
setType('markdown')
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
@@ -556,11 +557,11 @@ export function NoteInput({
const handleSubmit = async () => {
// Validation: Allow submit if content OR images OR links exist
const hasContent = content.trim().length > 0;
const hasMedia = images.length > 0 || links.length > 0;
const hasCheckItems = checkItems.some(i => i.text.trim().length > 0);
const hasContent = type === 'checklist' ? false : content.trim().length > 0
const hasCheckItems = type === 'checklist' ? checkItems.some(item => item.text.trim()) : false
const hasMedia = (images && images.length > 0) || (links && links.length > 0)
if (type === 'text' && !hasContent && !hasMedia) {
if (type !== 'checklist' && !hasContent && !hasMedia) {
toast.warning(t('notes.contentOrMediaRequired'))
return
}
@@ -573,7 +574,7 @@ export function NoteInput({
try {
const createdNote = await createNote({
title: title.trim() || undefined,
content: type === 'text' ? content : '',
content: type === 'checklist' ? '' : content,
type,
checkItems: type === 'checklist' ? checkItems : undefined,
color,
@@ -597,12 +598,11 @@ export function NoteInput({
setCheckItems([])
setImages([])
setLinks([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
setIsExpanded(false)
setType('text')
setType('richtext')
setColor('default')
setIsArchived(false)
setCurrentReminder(null)
@@ -643,7 +643,6 @@ export function NoteInput({
setCheckItems([])
setImages([])
setLinks([])
setIsMarkdown(false)
setShowMarkdownPreview(false)
setHistory([{ title: '', content: '' }])
setHistoryIndex(0)
@@ -765,7 +764,13 @@ export function NoteInput({
{/* Content area — scrolls internally when constrained by max-h */}
<div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto">
{type === 'text' ? (
{type === 'richtext' ? (
<RichTextEditor
content={content}
onChange={setContent}
className="min-h-[120px]"
/>
) : type === 'text' || type === 'markdown' ? (
<>
{showMarkdownPreview && isMarkdown ? (
<MarkdownContent
@@ -929,16 +934,9 @@ export function NoteInput({
</Button>
</TooltipTrigger><TooltipContent>{t('notes.remindMe')}</TooltipContent></Tooltip>
{type === 'text' && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className={cn('h-8 w-8', isMarkdown && 'text-primary bg-primary/10')}
onClick={() => { setIsMarkdown(!isMarkdown); if (isMarkdown) setShowMarkdownPreview(false) }}>
<FileText className="h-4 w-4" />
</Button>
</TooltipTrigger><TooltipContent>{t('notes.markdown')}</TooltipContent></Tooltip>
)}
<NoteTypeSelector value={type} onChange={(newType) => { setType(newType); if (newType !== 'markdown') setShowMarkdownPreview(false) }} />
{type === 'text' && isMarkdown && (
{type === 'markdown' && (
<Tooltip><TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={() => setShowMarkdownPreview(!showMarkdownPreview)}>