feat: standardize UI theme, fix dark mode consistency, and implement editorial tags
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m24s

This commit is contained in:
Antigravity
2026-05-10 18:43:13 +00:00
parent f6880bd0e1
commit 330c0c61b6
25 changed files with 640 additions and 503 deletions

View File

@@ -26,30 +26,8 @@ export function NoteContentArea() {
return data.url
}
if (state.noteType === 'richtext') {
if (fullPage) {
return (
<div className="fullpage-editor">
<RichTextEditor
content={state.content}
onChange={(v: string) => actions.setContent(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
/>
</div>
)
}
return (
<RichTextEditor
content={state.content}
onChange={actions.setContent}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
/>
)
}
if (state.noteType === 'markdown' && state.showMarkdownPreview) {
// Markdown preview mode
if (state.isMarkdown && state.showMarkdownPreview) {
return (
<div
className={cn(
@@ -68,7 +46,8 @@ export function NoteContentArea() {
)
}
if (state.noteType === 'markdown' || state.noteType === 'text') {
// Markdown edit mode
if (state.isMarkdown) {
if (fullPage) {
return (
<div className="relative">
@@ -93,12 +72,11 @@ export function NoteContentArea() {
)
}
// Dialog mode
return (
<div className="space-y-2">
<Textarea
dir="auto"
placeholder={state.isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
placeholder={t('notes.takeNoteMarkdown') || t('notes.takeNote')}
value={state.content}
onChange={(e) => actions.setContent(e.target.value)}
disabled={readOnly}
@@ -118,62 +96,35 @@ export function NoteContentArea() {
)
}
// Checklist mode
// Richtext mode (default)
if (fullPage) {
return (
<div className="space-y-2">
{state.checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox
checked={item.checked}
onCheckedChange={() => actions.handleCheckItem(item.id)}
className="mt-2"
/>
<Input
value={item.text}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
<div className="fullpage-editor">
<RichTextEditor
content={state.content}
onChange={(v: string) => actions.setContent(v)}
className="min-h-[280px]"
onImageUpload={uploadImageFile}
/>
</div>
)
}
return (
<div className="space-y-2">
{state.checkItems.map((item) => (
<div key={item.id} className="flex items-start gap-2 group">
<Checkbox
checked={item.checked}
onCheckedChange={() => actions.handleCheckItem(item.id)}
className="mt-2"
/>
<Input
value={item.text}
onChange={(e) => actions.handleUpdateCheckItem(item.id, e.target.value)}
placeholder={t('notes.listItem')}
className="flex-1 border-0 focus-visible:ring-0 px-0 bg-transparent"
/>
<Button variant="ghost" size="sm" className="opacity-0 group-hover:opacity-100 h-8 w-8 p-0"
onClick={() => actions.handleRemoveCheckItem(item.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="ghost" size="sm" onClick={actions.handleAddCheckItem} className="text-gray-600 dark:text-gray-400">
<Plus className="h-4 w-4 mr-1" />
{t('notes.addItem')}
</Button>
<RichTextEditor
content={state.content}
onChange={actions.setContent}
className="min-h-[200px]"
onImageUpload={uploadImageFile}
/>
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
</div>
)
}
}

View File

@@ -2,7 +2,7 @@
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, NoteSize } from '@/lib/types'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
import { updateNote, createNote, cleanupOrphanedImages, leaveSharedNote, deleteNote } from '@/app/actions/notes'
import { fetchLinkMetadata } from '@/app/actions/scrape'
import { useNotebooks } from '@/context/notebooks-context'
@@ -48,7 +48,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}, [session?.user?.id])
// Core content state
const [title, setTitle] = useState(note.title || '')
const [content, setContent] = useState(note.content)
const [checkItems, setCheckItems] = useState<CheckItem[]>(note.checkItems || [])
@@ -60,16 +59,13 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const [size, setSize] = useState<NoteSize>(note.size || 'small')
const [isSaving, setIsSaving] = useState(false)
const [removedImageUrls, setRemovedImageUrls] = useState<string[]>([])
const [noteType, setNoteType] = useState<NoteType>(note.type)
const isMarkdown = noteType === 'markdown'
const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown')
const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown')
// Refs
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const prevNoteRef = useRef(note)
// CRITICAL: Sync state when note.id changes (lines 101-116 from original)
useEffect(() => {
if (note.id !== prevNoteRef.current.id || note.content !== prevNoteRef.current.content || note.title !== prevNoteRef.current.title) {
setTitle(note.title || '')
@@ -80,40 +76,54 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setLinks(note.links || [])
setColor(note.color)
setSize(note.size || 'small')
setNoteType(note.type)
setIsMarkdown(note.type === 'markdown')
setShowMarkdownPreview(note.type === 'markdown')
setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null)
}
prevNoteRef.current = note
}, [note])
// Update context notebookId when note changes
useEffect(() => {
setContextNotebookId(note.notebookId || null)
}, [note.notebookId, setContextNotebookId])
// Auto-tagging hook
const [dismissedTags, setDismissedTags] = useState<string[]>([])
const dismissedTagsLoadedRef = useRef(false)
useEffect(() => {
dismissedTagsLoadedRef.current = false
try {
const stored = localStorage.getItem(`dismissed-tags-${note.id}`)
if (stored) {
setDismissedTags(JSON.parse(stored))
dismissedTagsLoadedRef.current = true
} else {
setDismissedTags([])
}
} catch (_) {
setDismissedTags([])
}
}, [note.id])
const autoTaggingEnabled = autoLabelingEnabled && dismissedTags.length < 3
const { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
content: noteType !== 'checklist' ? content : '',
content: content,
notebookId: note.notebookId,
enabled: noteType !== 'checklist' && autoLabelingEnabled
enabled: autoTaggingEnabled
})
// Reminder state
const [showReminderDialog, setShowReminderDialog] = useState(false)
const [currentReminder, setCurrentReminder] = useState<Date | null>(
note.reminder ? new Date(note.reminder as unknown as string) : null
)
// Link state
const [showLinkDialog, setShowLinkDialog] = useState(false)
const [linkUrl, setLinkUrl] = useState('')
// Title suggestions state
const [titleSuggestions, setTitleSuggestions] = useState<TitleSuggestion[]>([])
const [isGeneratingTitles, setIsGeneratingTitles] = useState(false)
// Reformulation state
const [isReformulating, setIsReformulating] = useState(false)
const [reformulationModal, setReformulationModal] = useState<{
originalText: string
@@ -121,38 +131,28 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
option: string
} | null>(null)
// AI processing state
const [isProcessingAI, setIsProcessingAI] = useState(false)
const [aiOpen, setAiOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
const [isDirty, setIsDirty] = useState(false)
// fullPage — auto title suggestions
const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false)
const { suggestions: autoTitleSuggestions } = useTitleSuggestions({
content,
enabled: fullPage && !title && !dismissedTitleSuggestions,
})
// Wire autoTitleSuggestions into state so NoteTitleBlock can display them
useEffect(() => {
if (autoTitleSuggestions.length > 0) {
setTitleSuggestions(autoTitleSuggestions)
}
}, [autoTitleSuggestions])
// Track previous content for copilot action undo
const [previousContentForCopilot, setPreviousContentForCopilot] = useState<string | null>(null)
// Memory Echo Connections state
const [comparisonNotes, setComparisonNotes] = useState<Array<Partial<Note>>>([])
const [fusionNotes, setFusionNotes] = useState<Array<Partial<Note>>>([])
// Tags dismissed by the user for this session
const [dismissedTags, setDismissedTags] = useState<string[]>([])
// Filter suggestions to exclude dismissed ones
// and those already present on the note
const existingLabelsLower = (note.labels || []).map((l) => l.toLowerCase())
const filteredSuggestions = suggestions.filter(s => {
if (!s || !s.tag) return false
@@ -185,10 +185,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}
// Paste handler: upload clipboard images
useEffect(() => {
const handlePaste = async (e: ClipboardEvent) => {
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
if (!isMarkdown && (e.target as HTMLElement)?.closest('.notion-editor')) return;
const items = e.clipboardData?.items
if (!items) return
for (const item of Array.from(items)) {
@@ -207,9 +206,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
document.addEventListener('paste', handlePaste, { capture: true })
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
}, [t, noteType])
}, [t, isMarkdown])
// Auto-grow textarea as content grows
useEffect(() => {
const el = textareaRef.current
if (!el) return
@@ -217,10 +215,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
}, [content])
// Also auto-grow when switching FROM preview TO edit mode
useEffect(() => {
if (showMarkdownPreview) return // we're in preview, textarea not mounted
// Defer one frame so the textarea is in the DOM
if (showMarkdownPreview) return
const raf = requestAnimationFrame(() => {
const el = textareaRef.current
if (!el) return
@@ -234,7 +230,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleRemoveImage = (index: number) => {
const removedUrl = images[index]
setImages(images.filter((_, i) => i !== index))
// Track removed images for cleanup on save
if (removedUrl) {
setRemovedImageUrls(prev => [...prev, removedUrl])
}
@@ -267,9 +262,9 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
const allImages = useMemo(() => {
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
const extracted = !isMarkdown ? extractImagesFromHTML(content) : [];
return Array.from(new Set([...images, ...extracted]));
}, [images, content, noteType]);
}, [images, content, isMarkdown]);
const handleGenerateTitles = async () => {
const fullContentForAI = [
@@ -301,7 +296,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const data = await response.json()
setTitleSuggestions(data.suggestions || [])
// Auto-apply first title for dialog mode (fullPage shows suggestions UI instead)
if (!fullPage && data.suggestions?.[0]?.title) {
setTitle(data.suggestions[0].title)
setDismissedTitleSuggestions(true)
@@ -485,7 +479,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
setContent(data.transformedText)
setNoteType('markdown')
setIsMarkdown(true)
setShowMarkdownPreview(false)
toast.success(t('ai.transformSuccess'))
@@ -500,13 +494,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
const handleApplyRefactor = () => {
if (!reformulationModal) return
const selectedText = window.getSelection()?.toString()
if (selectedText) {
setContent(reformulationModal.reformulatedText)
} else {
setContent(reformulationModal.reformulatedText)
}
setContent(reformulationModal.reformulatedText)
setReformulationModal(null)
toast.success(t('ai.reformulationApplied'))
}
@@ -536,35 +524,27 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
const handleSave = async () => {
console.log('[SAVE] handleSave called, note.id:', note.id)
setIsSaving(true)
try {
console.log('[SAVE] Calling updateNote...')
const result = await updateNote(note.id, {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
content,
checkItems: null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown: noteType === 'markdown',
type: noteType,
isMarkdown,
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
})
console.log('[SAVE] updateNote succeeded, result title:', result?.title, 'result content len:', result?.content?.length)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
// Keep local note ref in sync with saved data so useEffect detects changes correctly
prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 80))
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
@@ -607,6 +587,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
if (!tagExists) {
setLabels(prev => [...prev, tag])
setIsDirty(true)
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
if (!globalExists) {
@@ -621,11 +602,16 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
const handleDismissGhostTag = (tag: string) => {
setDismissedTags(prev => [...prev, tag])
setDismissedTags(prev => {
const next = [...prev, tag]
try { localStorage.setItem(`dismissed-tags-${note.id}`, JSON.stringify(next)) } catch (_) {}
return next
})
}
const handleRemoveLabel = (label: string) => {
setLabels(labels.filter(l => l !== label))
setIsDirty(true)
}
const handleMakeCopy = async () => {
@@ -638,54 +624,42 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
labels: labels,
images: images,
links: links,
isMarkdown: noteType === 'markdown',
type: noteType,
isMarkdown,
type: isMarkdown ? 'markdown' : 'richtext',
size: size,
})
toast.success(t('notes.copySuccess'))
// Invalidate notes list cache for current notebook
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
// Note: onClose is handled by the composition component
} catch (error) {
console.error('Failed to copy note:', error)
toast.error(t('notes.copyFailed'))
}
}
// Save in place (fullPage) — without closing
const handleSaveInPlace = async () => {
console.log('[SAVE] handleSaveInPlace called, note.id:', note.id, 'content length:', content.length, 'title:', title.substring(0, 50))
setIsSaving(true)
try {
console.log('[SAVE] Calling updateNote with note.id:', note.id, '| content len:', content.length, '| title:', title.substring(0, 30))
const updatePayload = {
title: title.trim() || null,
content: noteType !== 'checklist' ? content : '',
checkItems: noteType === 'checklist' ? checkItems : null,
content,
checkItems: null,
labels,
images,
links,
color,
reminder: currentReminder,
isMarkdown: noteType === 'markdown',
type: noteType,
isMarkdown,
type: isMarkdown ? 'markdown' as const : 'richtext' as const,
size,
}
console.log('[SAVE] payload.content:', JSON.stringify(updatePayload.content)?.substring(0, 100))
const result = await updateNote(note.id, updatePayload)
console.log('[SAVE] updateNote succeeded, result.id:', result?.id, '| result.content len:', result?.content?.length, '| result.title:', result?.title)
console.log('[SAVE] prevNoteRef BEFORE sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
// Sync local note reference with saved data so prop/state stay aligned after save
prevNoteRef.current = { ...prevNoteRef.current, ...result }
console.log('[SAVE] prevNoteRef AFTER sync:', JSON.stringify(prevNoteRef.current.content)?.substring(0, 50))
if (removedImageUrls.length > 0) {
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
}
await refreshLabels()
// Notify parent with the freshly-saved note so it can update its local state immediately
onNoteSaved?.(result)
// Invalidate note and notes list cache
queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) })
queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) })
triggerRefresh()
@@ -699,7 +673,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
}
}
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
useEffect(() => {
if (!fullPage) return
const handler = (e: KeyboardEvent) => {
@@ -712,7 +685,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return () => document.removeEventListener('keydown', handler)
}, [fullPage, isSaving])
// Build state object
const state: NoteEditorState = useMemo(() => ({
title,
content,
@@ -723,7 +695,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
newLabel,
color: color as NoteColor,
size,
noteType,
showMarkdownPreview,
removedImageUrls,
isSaving,
@@ -750,7 +721,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
allImages,
colorClasses,
}), [
title, content, checkItems, labels, images, links, newLabel, color, size, noteType,
title, content, checkItems, labels, images, links, newLabel, color, size,
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
@@ -758,10 +729,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
])
// Build actions object — NOT memoized to avoid stale closures.
// handleSave / handleSaveInPlace close over content, title, labels, etc.
// which change on every keystroke. Memoizing with [] would freeze those
// values at the first render, causing the wrong content to be saved.
const actions: NoteEditorActions = {
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
setDismissedTitleSuggestions,
@@ -782,8 +749,8 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setLinks,
handleAddLink,
handleRemoveLink,
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
setIsMarkdown: (m) => { setIsMarkdown(m); setIsDirty(true) },
setColor: (c) => { setColor(c); setIsDirty(true) },
setSize: (s) => { setSize(s); setIsDirty(true) },
setShowReminderDialog,
@@ -815,7 +782,6 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
setPreviousContentForCopilot,
}
const value: NoteEditorContextValue = useMemo(() => ({
note,
readOnly,
@@ -841,4 +807,4 @@ export function useNoteEditorContext() {
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
}
return context
}
}

View File

@@ -207,6 +207,8 @@ export function NoteEditorDialog({ onClose }: NoteEditorDialogProps) {
} : undefined}
lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks}
notebookId={note.notebookId ?? undefined}
notebookName={notebooks.find(nb => nb.id === note.notebookId)?.name ?? undefined}
/>
)}
</DialogContent>

View File

@@ -14,23 +14,30 @@ import { format } from 'date-fns'
import { ChevronRight } from 'lucide-react'
import { toast } from 'sonner'
import { Note } from '@/lib/types'
import { GhostTags } from '@/components/ghost-tags'
import { LabelBadge } from '@/components/label-badge'
interface NoteEditorFullPageProps {
onClose: () => void
}
export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
const { state, actions, note, readOnly, notebooks, fileInputRef } = useNoteEditorContext()
const { state, actions, note, readOnly, notebooks, fileInputRef, globalLabels } = useNoteEditorContext()
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
const getLabelType = (name: string): 'ai' | 'user' => {
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
return (found as any)?.type === 'ai' ? 'ai' : 'user'
}
return (
<>
{/* ── outer container ── */}
<div className="h-full flex items-stretch overflow-hidden transition-all duration-500">
{/* ── main scrollable column ── */}
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-zinc-950">
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-background">
{/* TOOLBAR */}
<NoteEditorToolbar mode="fullPage" onClose={onClose} />
@@ -51,6 +58,28 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
{/* Title */}
<NoteTitleBlock />
{(state.labels.length > 0 || (state.filteredSuggestions.length > 0)) && (
<div className="flex flex-wrap gap-2 pt-2">
{state.labels.map((label) => (
<LabelBadge
key={label}
label={label}
type={getLabelType(label)}
onRemove={() => actions.handleRemoveLabel(label)}
/>
))}
{!readOnly && (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}
isAnalyzing={state.isAnalyzingSuggestions}
onSelectTag={actions.handleSelectGhostTag}
onDismissTag={actions.handleDismissGhostTag}
/>
)}
</div>
)}
</div>
{/* Hero image — show first note image if present */}
@@ -83,12 +112,14 @@ export function NoteEditorFullPage({ onClose }: NoteEditorFullPageProps) {
onApplyToNote={(nc: string) => {
actions.setPreviousContentForCopilot(state.content)
actions.setContent(nc)
if (state.noteType === 'markdown') actions.setShowMarkdownPreview(true)
if (state.isMarkdown) actions.setShowMarkdownPreview(true)
}}
onUndoLastAction={state.previousContentForCopilot !== null ? () => { actions.setContent(state.previousContentForCopilot!); actions.setPreviousContentForCopilot(null) } : undefined}
lastActionApplied={state.previousContentForCopilot !== null}
notebooks={notebooks}
diagramInsertFormat={state.noteType === 'richtext' ? 'html' : 'markdown'}
notebookId={note.notebookId ?? undefined}
notebookName={notebookName ?? undefined}
diagramInsertFormat={state.isMarkdown ? 'markdown' : 'html'}
onGenerateTitle={async () => {
const plain = state.content.replace(/<[^>]+>/g, ' ').trim()
const wordCount = plain.split(/\s+/).filter(Boolean).length

View File

@@ -7,7 +7,6 @@ import { LabelBadge } from '@/components/label-badge'
import { GhostTags } from '@/components/ghost-tags'
import { EditorImages } from '@/components/editor-images'
import { TitleSuggestions } from '@/components/title-suggestions'
import { NoteTypeSelector } from '@/components/note-type-selector'
import {
DropdownMenu,
DropdownMenuContent,
@@ -44,32 +43,28 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const notebookName = notebooks.find(nb => nb.id === note.notebookId)?.name || null
// Snapshot for undo — stored in a ref so the toast callback isn't a stale closure
const undoSnapshotRef = useRef<{ content: string; noteType: string } | null>(null)
const undoSnapshotRef = useRef<{ content: string; isMarkdown: boolean } | null>(null)
const handleConvertToRichtext = async () => {
if (isConverting || !state.content.trim()) return
setIsConverting(true)
// Capture snapshot BEFORE converting
const snapshot = { content: state.content, noteType: state.noteType }
const snapshot = { content: state.content, isMarkdown: state.isMarkdown }
undoSnapshotRef.current = snapshot
try {
let html: string
if (state.noteType === 'markdown') {
// Proper markdown → HTML via marked (no AI needed)
if (state.isMarkdown) {
const { marked } = await import('marked')
html = await marked(state.content, { async: false }) as string
} else {
// Plain text → wrap paragraphs in <p> tags
html = state.content
.split(/\n{2,}/)
.map(para => `<p>${para.trim().replace(/\n/g, '<br />')}</p>`)
.join('')
}
actions.setContent(html)
actions.setNoteType('richtext')
actions.setIsMarkdown(false)
toast.success(t('notes.convertedToRichText') || 'Converted to rich text', {
duration: 8000,
@@ -79,7 +74,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
const snap = undoSnapshotRef.current
if (!snap) return
actions.setContent(snap.content)
actions.setNoteType(snap.noteType as any)
if (snap.isMarkdown) actions.setIsMarkdown(true)
undoSnapshotRef.current = null
toast.info(t('ai.undoApplied') || 'Conversion undone')
},
@@ -94,8 +89,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
if (mode === 'fullPage') {
return (
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-zinc-950/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
{/* Left: back */}
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/95 dark:bg-background/95 backdrop-blur-sm z-40 border-b border-border dark:border-white/10">
<button
onClick={onClose}
className="flex items-center gap-2 text-foreground hover:opacity-60 transition-opacity"
@@ -104,9 +98,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
<span className="text-sm font-medium">Back to collection</span>
</button>
{/* Right: status + type + AI + Info */}
<div className="flex items-center gap-4">
{/* Save status */}
<span className="hidden sm:flex items-center gap-1.5 text-[11px] text-foreground/40 select-none">
{state.isSaving
? <><Loader2 className="h-3 w-3 animate-spin" /><span>Saving</span></>
@@ -115,15 +107,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
: <><Check className="h-3 w-3 text-emerald-500" /><span>Saved</span></>}
</span>
{/* Note type */}
<NoteTypeSelector
value={state.noteType}
onChange={(newType) => { actions.setNoteType(newType); actions.setIsDirty(true) }}
compact
/>
{/* Preview toggle — icon only */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
{state.isMarkdown && !readOnly && (
<button
title={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Aperçu'}
aria-label={state.showMarkdownPreview ? 'Revenir à l\'édition' : 'Prévisualiser le rendu'}
@@ -139,8 +123,7 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button>
)}
{/* Convert to Rich Text — icon only */}
{(state.noteType === 'text' || state.noteType === 'markdown') && !readOnly && (
{state.isMarkdown && !readOnly && (
<button
title={t('ai.convertToRichtext') || 'Convert to Rich Text'}
aria-label={t('ai.convertToRichtext') || 'Convert to Rich Text'}
@@ -156,7 +139,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button>
)}
{/* AI — icon only */}
<button
title="AI Assistant"
aria-label="Ouvrir l'assistant IA"
@@ -171,7 +153,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
<Sparkles size={16} />
</button>
{/* Save — icon only */}
{!readOnly && (
<button
title={state.isDirty ? 'Enregistrer' : 'Aucune modification'}
@@ -189,7 +170,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button>
)}
{/* Share button */}
{!readOnly && (
<button
title="Partager la note"
@@ -201,8 +181,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</button>
)}
{/* Three-dot options menu */}
{!readOnly && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -229,7 +207,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenu>
)}
{/* Share Dialog portal */}
{shareOpen && (
<NoteShareDialog
noteId={note.id}
@@ -238,7 +215,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
/>
)}
{/* Info panel toggle — rightmost, icon only */}
<button
aria-label="Informations du document"
onClick={() => { actions.setInfoOpen(!state.infoOpen); actions.setAiOpen(false) }}
@@ -256,32 +232,26 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
)
}
// Dialog toolbar
return (
<div className="flex items-center justify-between pt-3 border-t border-border/30">
<div className="flex items-center gap-0.5">
{!readOnly && (
<>
{/* Reminder */}
<Button variant="ghost" size="icon" className={cn('h-8 w-8 rounded-md', state.currentReminder && 'text-primary')}
onClick={() => actions.setShowReminderDialog(true)} title={t('notes.setReminder')}>
<Bell className="h-4 w-4" />
</Button>
{/* Add Image */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => fileInputRef.current?.click()} title={t('notes.addImage')}>
<ImageIcon className="h-4 w-4" />
</Button>
{/* Add Link */}
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowLinkDialog(true)} title={t('notes.addLink')}>
<LinkIcon className="h-4 w-4" />
</Button>
<NoteTypeSelector value={state.noteType} onChange={(newType) => { actions.setNoteType(newType); if (newType !== 'markdown') actions.setShowMarkdownPreview(false) }} />
{state.noteType === 'markdown' && (
{state.isMarkdown && (
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md"
onClick={() => actions.setShowMarkdownPreview(!state.showMarkdownPreview)}
title={state.showMarkdownPreview ? t('general.edit') : t('notes.preview')}>
@@ -289,17 +259,13 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</Button>
)}
{/* AI Copilot */}
{state.noteType !== 'checklist' && (
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
</Button>
)}
<Button variant="ghost" size="sm"
className={cn('h-8 gap-1.5 px-2 text-xs font-medium transition-all duration-200 rounded-md', state.aiOpen && 'bg-primary/10 text-primary')}
onClick={() => actions.setAiOpen(!state.aiOpen)} title="IA Note">
<Sparkles className="h-3.5 w-3.5" />
<span className="hidden sm:inline">IA Note</span>
</Button>
{/* Size Selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeSize')}>
@@ -319,7 +285,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* Color Picker */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-md" title={t('notes.changeColor')}>
@@ -338,7 +303,6 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* Label Manager */}
<LabelManager existingLabels={state.labels} notebookId={note.notebookId} onUpdate={actions.setLabels} />
</>
)}
@@ -394,4 +358,4 @@ export function NoteEditorToolbar({ mode, onClose }: NoteEditorToolbarProps) {
</div>
</div>
)
}
}

View File

@@ -6,7 +6,12 @@ import { GhostTags } from '../ghost-tags'
import { cn } from '@/lib/utils'
export function NoteMetadataSection() {
const { state, actions, readOnly } = useNoteEditorContext()
const { state, actions, readOnly, globalLabels } = useNoteEditorContext()
const getLabelType = (name: string): 'ai' | 'user' => {
const found = globalLabels.find(l => l.name.toLowerCase() === name.toLowerCase())
return (found as any)?.type === 'ai' ? 'ai' : 'user'
}
return (
<div className="space-y-4">
@@ -17,6 +22,7 @@ export function NoteMetadataSection() {
<LabelBadge
key={label}
label={label}
type={getLabelType(label)}
onRemove={() => actions.handleRemoveLabel(label)}
/>
))}
@@ -24,7 +30,7 @@ export function NoteMetadataSection() {
)}
{/* Ghost Tags - only show in dialog mode */}
{!readOnly && state.noteType !== 'richtext' && (
{!readOnly && !state.isMarkdown && (
<GhostTags
suggestions={state.filteredSuggestions}
addedTags={state.labels}

View File

@@ -1,10 +1,8 @@
import { Note, CheckItem, NOTE_COLORS, NoteColor, NoteType, LinkMetadata, NoteSize } from '@/lib/types'
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteSize } from '@/lib/types'
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
import type { TagSuggestion } from '@/lib/ai/types'
// State interface - all local state from NoteEditor
export interface NoteEditorState {
// Core content state
title: string
content: string
checkItems: CheckItem[]
@@ -14,15 +12,12 @@ export interface NoteEditorState {
newLabel: string
color: NoteColor
size: NoteSize
noteType: NoteType
// UI state
showMarkdownPreview: boolean
removedImageUrls: string[]
isSaving: boolean
isDirty: boolean
// AI state
isProcessingAI: boolean
aiOpen: boolean
infoOpen: boolean
@@ -37,107 +32,84 @@ export interface NoteEditorState {
} | null
previousContentForCopilot: string | null
// Reminder state
showReminderDialog: boolean
currentReminder: Date | null
// Link dialog state
showLinkDialog: boolean
linkUrl: string
// Memory Echo Connections
comparisonNotes: Array<Partial<Note>>
fusionNotes: Array<Partial<Note>>
// Ghost tags
dismissedTags: string[]
// Tag suggestions (from auto-tagging)
filteredSuggestions: TagSuggestion[]
isAnalyzingSuggestions: boolean
// Context-derived values
isMarkdown: boolean
allImages: string[]
colorClasses: typeof NOTE_COLORS[keyof typeof NOTE_COLORS]
}
// Actions interface - all handlers from NoteEditor
export interface NoteEditorActions {
// Title actions
setTitle: (title: string) => void
setDismissedTitleSuggestions: (dismissed: boolean) => void
// Content actions
setContent: (content: string) => void
// CheckItems actions
setCheckItems: (items: CheckItem[]) => void
handleCheckItem: (id: string) => void
handleUpdateCheckItem: (id: string, text: string) => void
handleAddCheckItem: () => void
handleRemoveCheckItem: (id: string) => void
// Labels actions
setLabels: (labels: string[]) => void
handleSelectGhostTag: (tag: string) => void
handleDismissGhostTag: (tag: string) => void
handleRemoveLabel: (label: string) => void
// Images actions
setImages: (images: string[]) => void
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void
handleRemoveImage: (index: number) => void
uploadImageFile: (file: File) => Promise<string>
// Links actions
setLinks: (links: LinkMetadata[]) => void
handleAddLink: () => Promise<void>
handleRemoveLink: (index: number) => void
// Note properties
setNoteType: (type: NoteType) => void
setShowMarkdownPreview: (show: boolean) => void
setIsMarkdown: (markdown: boolean) => void
setColor: (color: NoteColor) => void
setSize: (size: NoteSize) => void
// Reminder actions
setShowReminderDialog: (show: boolean) => void
setCurrentReminder: (date: Date | null) => void
handleReminderSave: (date: Date) => Promise<void>
handleRemoveReminder: () => Promise<void>
// Link dialog
setShowLinkDialog: (show: boolean) => void
setLinkUrl: (url: string) => void
// Title suggestions
handleGenerateTitles: () => Promise<void>
handleSelectTitle: (title: string) => void
// Reformulation
handleReformulate: (option: 'clarify' | 'shorten' | 'improve') => Promise<void>
handleApplyRefactor: () => void
// AI Direct handlers
handleClarifyDirect: () => Promise<void>
handleShortenDirect: () => Promise<void>
handleImproveDirect: () => Promise<void>
handleTransformMarkdown: () => Promise<void>
// Save actions
handleSave: () => Promise<void>
handleSaveInPlace: () => Promise<void>
handleMakeCopy: () => Promise<void>
// Memory Echo
setComparisonNotes: (notes: Array<Partial<Note>>) => void
setFusionNotes: (notes: Array<Partial<Note>>) => void
// Modal states
setReformulationModal: (modal: NoteEditorState['reformulationModal']) => void
// State setters
setIsDirty: (dirty: boolean) => void
setAiOpen: (open: boolean) => void
setInfoOpen: (open: boolean) => void
@@ -147,28 +119,14 @@ export interface NoteEditorActions {
setPreviousContentForCopilot: (content: string | null) => void
}
// Context value - combines state + actions + note reference
export interface NoteEditorContextValue {
// The current note (external source of truth)
note: Note
// Read-only flag
readOnly: boolean
// FullPage flag
fullPage: boolean
// All state
state: NoteEditorState
// All actions
actions: NoteEditorActions
// Computed values from contexts
notebooks: Array<{ id: string; name: string }>
globalLabels: Array<{ name: string }>
// Refs
fileInputRef: React.RefObject<HTMLInputElement | null>
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}
}