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

@@ -19,6 +19,10 @@ import {
Trash2,
RotateCcw,
History,
AlignLeft,
FileCode2,
PenLine,
ListChecks,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { NOTE_COLORS } from "@/lib/types"
@@ -38,6 +42,7 @@ interface NoteActionsProps {
onDelete: () => void
onShareCollaborators?: () => void
isMarkdown?: boolean
noteType?: string
onToggleMarkdown?: () => void
isTrashView?: boolean
onRestore?: () => void
@@ -62,6 +67,7 @@ export function NoteActions({
onDelete,
onShareCollaborators,
isMarkdown = false,
noteType = 'text',
onToggleMarkdown,
isTrashView,
onRestore,
@@ -170,19 +176,22 @@ export function NoteActions({
</DropdownMenuContent>
</DropdownMenu>
{/* Markdown Toggle */}
{onToggleMarkdown && (
<Button
variant="ghost"
size="sm"
className={cn("h-8 gap-1 px-2 text-xs", isMarkdown && "text-primary bg-primary/10")}
title="Markdown"
onClick={onToggleMarkdown}
>
<FileText className="h-4 w-4" />
<span className="hidden sm:inline">MD</span>
</Button>
)}
{onToggleMarkdown && (() => {
const iconMap: Record<string, React.ElementType> = { text: AlignLeft, markdown: FileCode2, richtext: PenLine, checklist: ListChecks }
const TypeIcon = iconMap[noteType] || FileText
return (
<Button
variant="ghost"
size="sm"
className={cn("h-8 gap-1 px-2 text-xs", noteType !== 'text' && "text-primary bg-primary/10")}
title={noteType === 'markdown' ? 'Markdown' : noteType === 'richtext' ? 'Rich Text' : noteType === 'checklist' ? 'Checklist' : 'Text'}
onClick={onToggleMarkdown}
>
<TypeIcon className="h-4 w-4" />
<span className="hidden sm:inline">{noteType === 'markdown' ? 'MD' : noteType === 'richtext' ? 'RT' : noteType === 'checklist' ? 'CL' : 'TXT'}</span>
</Button>
)
})()}
{/* More Options */}
<DropdownMenu>

View File

@@ -1,6 +1,6 @@
'use client'
import { Note, NOTE_COLORS, NoteColor } from '@/lib/types'
import { Note, NOTE_COLORS, NoteColor, NoteType } from '@/lib/types'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
@@ -20,7 +20,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2 } from 'lucide-react'
import { Pin, Bell, GripVertical, X, Link2, FolderOpen, StickyNote, LucideIcon, Folder, Briefcase, FileText, Zap, BarChart3, Globe, Sparkles, Book, Heart, Crown, Music, Building2, LogOut, Trash2, AlignLeft, FileCode2, PenLine, ListChecks } from 'lucide-react'
import { useState, useEffect, useTransition, useOptimistic, memo } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
@@ -83,6 +83,13 @@ function getDateLocale(language: string): Locale {
return localeMap[language] || enUS
}
const NOTE_TYPE_ICONS: Record<NoteType, LucideIcon> = {
text: AlignLeft,
markdown: FileCode2,
richtext: PenLine,
checklist: ListChecks,
}
// Map icon names to lucide-react components
const ICON_MAP: Record<string, LucideIcon> = {
'folder': Folder,
@@ -536,8 +543,12 @@ export const NoteCard = memo(function NoteCard({
{/* Title */}
{optimisticNote.title && (
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
{optimisticNote.title}
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight flex items-center gap-2">
{(() => {
const TypeIcon = NOTE_TYPE_ICONS[optimisticNote.type] || AlignLeft
return <TypeIcon className="h-4 w-4 shrink-0 text-muted-foreground/50" />
})()}
<span className="min-w-0 truncate">{optimisticNote.title}</span>
</h3>
)}
@@ -608,18 +619,20 @@ export const NoteCard = memo(function NoteCard({
)}
{/* Content */}
{optimisticNote.type === 'text' ? (
{optimisticNote.type === 'checklist' ? (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
) : optimisticNote.type === 'richtext' ? (
<div className="text-sm text-foreground line-clamp-10 rt-preview" dangerouslySetInnerHTML={{ __html: optimisticNote.content || '' }} />
) : (
<div className="text-sm text-foreground line-clamp-10">
<MarkdownContent
content={optimisticNote.content}
className="prose-h1:text-xl prose-h1:font-semibold prose-h1:leading-snug prose-h1:mt-1 prose-h1:mb-2 prose-h2:text-lg prose-h2:font-medium prose-h3:text-base prose-p:text-sm prose-p:leading-relaxed"
/>
</div>
) : (
<NoteChecklist
items={optimisticNote.checkItems || []}
onToggleItem={handleCheckItem}
/>
)}
{/* Labels - using shared LabelBadge component */}

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">

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">

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)}>

View File

@@ -0,0 +1,87 @@
'use client'
import { NoteType } from '@/lib/types'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Check, AlignLeft, FileCode2, PenLine, ListChecks, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useLanguage } from '@/lib/i18n'
const TYPE_ICONS: Record<NoteType, React.ElementType> = {
text: AlignLeft,
markdown: FileCode2,
richtext: PenLine,
checklist: ListChecks,
}
const TYPE_I18N_KEYS: Record<NoteType, string> = {
text: 'notes.typeText',
markdown: 'notes.typeMarkdown',
richtext: 'notes.typeRichText',
checklist: 'notes.typeChecklist',
}
interface NoteTypeSelectorProps {
value: NoteType
onChange: (type: NoteType) => void
compact?: boolean
className?: string
}
export function NoteTypeSelector({ value, onChange, compact = false, className }: NoteTypeSelectorProps) {
const { t } = useLanguage()
const CurrentIcon = TYPE_ICONS[value]
const types: NoteType[] = ['text', 'markdown', 'richtext', 'checklist']
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size={compact ? 'icon' : 'sm'}
className={cn(
'gap-1.5 shrink-0',
compact ? 'h-8 w-8' : 'h-8 px-2 text-xs font-medium',
value !== 'text' && 'text-primary bg-primary/10',
className
)}
title={t('notes.noteType')}
>
<CurrentIcon className="h-4 w-4" />
{!compact && (
<>
<span>{t(TYPE_I18N_KEYS[value])}</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48">
{types.map((type) => {
const Icon = TYPE_ICONS[type]
const isActive = type === value
return (
<DropdownMenuItem
key={type}
onClick={() => onChange(type)}
className={cn('gap-2 cursor-pointer', isActive && 'bg-accent')}
>
<div className="flex items-center gap-2 flex-1">
<Icon className="h-4 w-4" />
<span className="text-sm">{t(TYPE_I18N_KEYS[type])}</span>
</div>
{isActive && <Check className="h-3.5 w-3.5 text-primary" />}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
export { TYPE_ICONS, TYPE_I18N_KEYS }

View File

@@ -0,0 +1,451 @@
'use client'
import { useEffect, useRef, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import { BubbleMenu } from '@tiptap/react/menus'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Placeholder from '@tiptap/extension-placeholder'
import TiptapLink from '@tiptap/extension-link'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import TextAlign from '@tiptap/extension-text-align'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import Superscript from '@tiptap/extension-superscript'
import Subscript from '@tiptap/extension-subscript'
import Typography from '@tiptap/extension-typography'
import type { Editor } from '@tiptap/core'
import type { EditorState } from '@tiptap/pm/state'
import {
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Code,
Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare,
Quote, CodeXml, Minus, ImageIcon, Type, Highlighter, Link as LinkIcon,
Sparkles, Wand2, Scissors, Lightbulb, X, Check, ExternalLink,
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
} from 'lucide-react'
import { cn } from '@/lib/utils'
export interface RichTextEditorHandle {
getEditor: () => Editor | null
}
interface RichTextEditorProps {
content?: string
onChange?: (content: string) => void
className?: string
placeholder?: string
}
type SlashItem = {
title: string
description: string
icon: typeof Bold
category?: string
isImage?: boolean
isAi?: boolean
aiOption?: 'clarify' | 'shorten' | 'improve'
command: (editor: Editor) => void
}
const slashCommands: SlashItem[] = [
{ title: 'Text', description: 'Just start writing with plain text', icon: Pilcrow, category: 'Basic blocks', command: (e) => e.chain().focus().setParagraph().run() },
{ title: 'Heading 1', description: 'Big section heading', icon: Heading1, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 1 }).run() },
{ title: 'Heading 2', description: 'Medium section heading', icon: Heading2, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 2 }).run() },
{ title: 'Heading 3', description: 'Small section heading', icon: Heading3, category: 'Basic blocks', command: (e) => e.chain().focus().toggleHeading({ level: 3 }).run() },
{ title: 'Bullet List', description: 'Create a simple bullet list', icon: List, category: 'Basic blocks', command: (e) => e.chain().focus().toggleBulletList().run() },
{ title: 'Numbered List', description: 'Create a list with numbering', icon: ListOrdered, category: 'Basic blocks', command: (e) => e.chain().focus().toggleOrderedList().run() },
{ title: 'To-do List', description: 'Track tasks with checkboxes', icon: CheckSquare, category: 'Basic blocks', command: (e) => e.chain().focus().toggleTaskList().run() },
{ title: 'Quote', description: 'Capture a quote', icon: Quote, category: 'Basic blocks', command: (e) => e.chain().focus().toggleBlockquote().run() },
{ title: 'Code Block', description: 'Capture a code snippet', icon: CodeXml, category: 'Basic blocks', command: (e) => e.chain().focus().toggleCodeBlock().run() },
{ title: 'Divider', description: 'Visually divide blocks', icon: Minus, category: 'Basic blocks', command: (e) => e.chain().focus().setHorizontalRule().run() },
{ title: 'Image', description: 'Embed an image from URL', icon: ImageIcon, category: 'Media', isImage: true, command: () => {} },
{ title: 'Clarify', description: 'Make text clearer and easier to understand', icon: Lightbulb, category: 'AI', isAi: true, aiOption: 'clarify', command: () => {} },
{ title: 'Shorten', description: 'Make text more concise', icon: Scissors, category: 'AI', isAi: true, aiOption: 'shorten', command: () => {} },
{ title: 'Improve', description: 'Improve writing style and flow', icon: Wand2, category: 'AI', isAi: true, aiOption: 'improve', command: () => {} },
]
async function aiReformulate(text: string, option: 'clarify' | 'shorten' | 'improve'): Promise<string> {
const res = await fetch('/api/ai/reformulate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, option }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'AI failed')
return data.reformulatedText || data.text || text
}
function useImageInsert() {
const [open, setOpen] = useState(false)
const editorRef = useRef<Editor | null>(null)
const requestInsert = useCallback((editor: Editor) => {
editorRef.current = editor
setOpen(true)
}, [])
const confirm = useCallback((url: string) => {
if (url.trim() && editorRef.current) {
editorRef.current.chain().focus().setImage({ src: url.trim() }).run()
}
setOpen(false)
editorRef.current = null
}, [])
const cancel = useCallback(() => {
setOpen(false)
editorRef.current = null
}, [])
return { open, requestInsert, confirm, cancel }
}
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(
function RichTextEditor({ content, onChange, className, placeholder }, ref) {
const imageInsert = useImageInsert()
const editor = useEditor({
extensions: [
StarterKit.configure({ heading: { levels: [1, 2, 3] }, link: false, underline: false }),
Underline,
TiptapLink.configure({ openOnClick: false, autolink: true }),
Highlight.configure({ multicolor: false }),
Image.configure({ inline: false, allowBase64: true }),
TextAlign.configure({ types: ['heading', 'paragraph'] }),
TaskList,
TaskItem.configure({ nested: true }),
Superscript,
Subscript,
Typography,
Placeholder.configure({ placeholder: placeholder || "Type '/' for commands..." }),
],
content: content || '',
immediatelyRender: false,
editorProps: {
attributes: { class: 'notion-editor' },
},
onUpdate: ({ editor: e }) => {
onChange?.(e.getHTML())
},
})
useImperativeHandle(ref, () => ({ getEditor: () => editor }), [editor])
return (
<div className={cn('notion-editor-wrapper', className)}>
{editor && (
<BubbleMenu
editor={editor}
className="notion-bubble-menu"
shouldShow={({ editor: e, state }: { editor: Editor; state: EditorState }) => {
const { from, to } = state.selection
return from !== to && !e.isActive('codeBlock')
}}
>
<BubbleToolbar editor={editor} />
</BubbleMenu>
)}
{editor && <SlashCommandMenu editor={editor} onInsertImage={imageInsert.requestInsert} />}
<EditorContent editor={editor} />
{imageInsert.open && (
<ImageModal onConfirm={imageInsert.confirm} onCancel={imageInsert.cancel} />
)}
</div>
)
}
)
function ImageModal({ onConfirm, onCancel }: { onConfirm: (url: string) => void; onCancel: () => void }) {
const [url, setUrl] = useState('')
const [preview, setPreview] = useState<string | null>(null)
const [error, setError] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { inputRef.current?.focus() }, [])
const handleConfirm = () => {
if (!url.trim()) return
if (!/^https?:\/\/.+/i.test(url.trim())) { setError('Please enter a valid URL'); return }
onConfirm(url.trim())
}
return (
<div className="notion-overlay" onClick={onCancel}>
<div className="notion-image-modal" onClick={(e) => e.stopPropagation()}>
<div className="text-sm font-medium mb-3">Insert image</div>
<input
ref={inputRef}
type="url"
value={url}
onChange={(e) => { setUrl(e.target.value); setError(''); setPreview(null) }}
onKeyDown={(e) => { if (e.key === 'Enter') handleConfirm(); if (e.key === 'Escape') onCancel() }}
placeholder="https://example.com/image.png"
className="notion-modal-input"
/>
{url.trim() && !preview && (
<button onClick={() => setPreview(url.trim())} className="text-xs text-muted-foreground hover:text-foreground transition-colors underline mt-2">
Preview
</button>
)}
{preview && (
<div className="mt-2 rounded-lg overflow-hidden border border-border max-h-40">
<img src={preview} alt="" className="max-h-40 object-contain w-full" onError={() => { setPreview(null); setError('Failed to load image') }} />
</div>
)}
{error && <div className="mt-1.5 text-xs text-destructive">{error}</div>}
<div className="flex justify-end gap-2 mt-4">
<button onClick={onCancel} className="notion-modal-btn">Cancel</button>
<button onClick={handleConfirm} className="notion-modal-btn notion-modal-btn-primary">Insert</button>
</div>
</div>
</div>
)
}
function BubbleToolbar({ editor }: { editor: Editor | null }) {
const [, setTick] = useState(0)
const [aiOpen, setAiOpen] = useState(false)
const [aiLoading, setAiLoading] = useState(false)
const [linkOpen, setLinkOpen] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
const linkInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!editor) return
const h = () => setTick(t => t + 1)
editor.on('transaction', h)
editor.on('selectionUpdate', h)
return () => { editor.off('transaction', h); editor.off('selectionUpdate', h) }
}, [editor])
useEffect(() => {
if (linkOpen && linkInputRef.current) linkInputRef.current.focus()
}, [linkOpen])
if (!editor) return null
const marks = [
{ icon: Bold, active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
{ icon: Italic, active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
{ icon: UnderlineIcon, active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
{ icon: Strikethrough, active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
{ icon: Code, active: editor.isActive('code'), action: () => editor.chain().focus().toggleCode().run() },
{ icon: Highlighter, active: editor.isActive('highlight'), action: () => editor.chain().focus().toggleHighlight().run() },
]
const handleAI = async (option: 'clarify' | 'shorten' | 'improve') => {
const { from, to } = editor.state.selection
const text = editor.state.doc.textBetween(from, to, ' ')
if (!text || text.split(/\s+/).length < 5) return
setAiLoading(true)
setAiOpen(false)
try {
const result = await aiReformulate(text, option)
editor.chain().focus().insertContentAt({ from, to }, result).run()
} catch (err) {
console.error('AI error:', err)
} finally {
setAiLoading(false)
}
}
const openLinkEditor = () => {
const existing = editor.getAttributes('link').href || ''
setLinkUrl(existing)
setLinkOpen(true)
setAiOpen(false)
}
const applyLink = () => {
if (linkUrl.trim()) {
editor.chain().focus().extendMarkRange('link').setLink({ href: linkUrl.trim() }).run()
} else {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}
setLinkOpen(false)
}
if (linkOpen) {
return (
<div className="flex items-center gap-1.5 px-2 py-1.5">
<LinkIcon className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<input
ref={linkInputRef}
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink() }; if (e.key === 'Escape') { setLinkOpen(false); editor.chain().focus().run() } }}
placeholder="Paste or type a link..."
className="notion-inline-input"
/>
<button onClick={applyLink} className="notion-bubble-btn notion-bubble-btn-active"><Check className="w-3.5 h-3.5" /></button>
{editor.isActive('link') && (
<button onClick={() => { editor.chain().focus().extendMarkRange('link').unsetLink().run(); setLinkOpen(false) }} className="notion-bubble-btn">
<ExternalLink className="w-3.5 h-3.5" />
</button>
)}
<button onClick={() => { setLinkOpen(false); editor.chain().focus().run() }} className="notion-bubble-btn"><X className="w-3.5 h-3.5" /></button>
</div>
)
}
return (
<div className="flex items-center gap-0.5 px-1 py-0.5 relative">
{marks.map((m, i) => (
<button key={i} onClick={m.action} className={cn('notion-bubble-btn', m.active && 'notion-bubble-btn-active')}>
<m.icon className="w-3.5 h-3.5" />
</button>
))}
<div className="w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5" />
<button onClick={openLinkEditor} className={cn('notion-bubble-btn', editor.isActive('link') && 'notion-bubble-btn-active')}><LinkIcon className="w-3.5 h-3.5" /></button>
<button onClick={() => setAiOpen(!aiOpen)} className={cn('notion-bubble-btn', aiLoading && 'animate-pulse')}><Sparkles className="w-3.5 h-3.5" /></button>
{aiOpen && (
<div className="notion-ai-submenu">
<button className="notion-ai-subitem" onClick={() => handleAI('clarify')}><Lightbulb className="w-3.5 h-3.5 text-amber-500" /><span>Clarify</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('shorten')}><Scissors className="w-3.5 h-3.5 text-blue-500" /><span>Shorten</span></button>
<button className="notion-ai-subitem" onClick={() => handleAI('improve')}><Wand2 className="w-3.5 h-3.5 text-purple-500" /><span>Improve</span></button>
</div>
)}
</div>
)
}
function SlashCommandMenu({ editor, onInsertImage }: { editor: Editor; onInsertImage: (editor: Editor) => void }) {
const [isOpen, setIsOpen] = useState(false)
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(0)
const [coords, setCoords] = useState({ top: 0, left: 0 })
const [aiLoading, setAiLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const closeMenu = useCallback(() => { setIsOpen(false); setQuery(''); setSelectedIndex(0) }, [])
const deleteSlashText = useCallback(() => {
const { from, to } = editor.state.selection
const textBefore = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
const slashIdx = textBefore.lastIndexOf('/')
if (slashIdx >= 0) {
const deleteFrom = from - (textBefore.length - slashIdx)
editor.chain().focus().deleteRange({ from: deleteFrom, to }).run()
}
}, [editor])
const handleSelect = useCallback(async (item: SlashItem) => {
if (item.isImage) {
deleteSlashText()
closeMenu()
onInsertImage(editor)
} else if (item.isAi && item.aiOption) {
deleteSlashText()
closeMenu()
setAiLoading(true)
try {
const allText = editor.state.doc.textContent
if (!allText || allText.split(/\s+/).length < 5) return
const result = await aiReformulate(allText, item.aiOption)
editor.chain().focus().setContent(result).run()
} catch (err) {
console.error('AI slash error:', err)
} finally {
setAiLoading(false)
}
} else {
deleteSlashText()
item.command(editor)
closeMenu()
}
}, [editor, closeMenu, deleteSlashText, onInsertImage])
const filtered = slashCommands.filter(c => c.title.toLowerCase().includes(query.toLowerCase()))
const categories = filtered.reduce((acc, item) => {
const cat = item.category || 'Basic blocks'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
}, {} as Record<string, SlashItem[]>)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return
if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(i => (i + 1) % filtered.length); return }
if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(i => (i - 1 + filtered.length) % filtered.length); return }
if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) handleSelect(filtered[selectedIndex]); return }
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); return }
}
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [isOpen, selectedIndex, filtered, handleSelect, closeMenu])
useEffect(() => {
if (!isOpen) return
const updatePosition = () => {
const { from } = editor.state.selection
const c = editor.view.coordsAtPos(from)
setCoords({ top: c.bottom + 8, left: c.left })
}
updatePosition()
}, [isOpen, editor, query])
useEffect(() => {
const handleClick = () => { if (isOpen) closeMenu() }
document.addEventListener('click', handleClick)
return () => document.removeEventListener('click', handleClick)
}, [isOpen, closeMenu])
useEffect(() => {
const handler = () => {
const { from, to, empty } = editor.state.selection
if (!empty) { if (isOpen) closeMenu(); return }
const text = editor.state.doc.textBetween(Math.max(0, from - 50), from, '\n')
const m = text.match(/\/([^\s/]*)$/)
if (m) { setQuery(m[1]); setSelectedIndex(0); if (!isOpen) setIsOpen(true) }
else if (isOpen) closeMenu()
}
editor.on('update', handler)
editor.on('selectionUpdate', handler)
return () => { editor.off('update', handler); editor.off('selectionUpdate', handler) }
}, [editor, isOpen, closeMenu])
if (!isOpen || filtered.length === 0) return null
let flatIndex = -1
return (
<div ref={menuRef} className="notion-slash-menu" style={{ top: coords.top, left: coords.left }} onClick={(e) => e.stopPropagation()}>
{aiLoading && (
<div className="notion-slash-loading">
<Sparkles className="w-3.5 h-3.5 animate-spin" />
<span>AI is thinking...</span>
</div>
)}
{!aiLoading && Object.entries(categories).map(([cat, items]) => (
<div key={cat} className="notion-slash-section">
<div className="notion-slash-label">{cat}</div>
{items.map((item) => {
flatIndex++
const idx = flatIndex
return (
<button
key={item.title}
className={cn('notion-slash-item', idx === selectedIndex && 'notion-slash-item-selected')}
onClick={() => handleSelect(item)}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className={cn('notion-slash-icon', item.isAi && 'notion-slash-icon-ai')}>
<item.icon className="w-3 h-3" />
</div>
<span className="notion-slash-title">{item.title}</span>
<span className="notion-slash-desc">{item.description}</span>
</button>
)
})}
</div>
))}
</div>
)
}