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:
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
87
memento-note/components/note-type-selector.tsx
Normal file
87
memento-note/components/note-type-selector.tsx
Normal 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 }
|
||||
451
memento-note/components/rich-text-editor.tsx
Normal file
451
memento-note/components/rich-text-editor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user