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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user