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
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m33s
This commit is contained in:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user