refactor: split NoteEditor into focused components + consolidate contexts
Phase 1: NoteEditor Split (64KB → 9 focused components) - components/note-editor/: types.ts, context, toolbar, title-block, content-area, metadata-section, full-page, dialog compositions - Maintains backwards compatibility via re-export from note-editor.tsx Phase 2: Context Consolidation (5 → 3 contexts) - NotebooksContext absorbs LabelContext (labels CRUD) - EditorUIContext merges HomeViewContext + NotebookDragContext - Removed: LabelContext, home-view-context, notebook-drag-context Phase 3: React Query Infrastructure - Added QueryProvider with @tanstack/react-query - lib/query-keys.ts: centralized query key definitions - lib/query-hooks.ts: useNotes, useNotebooksQuery, useLabelsQuery - lib/use-refresh.ts: hybrid invalidateQueries + triggerRefresh helper - NotebooksContext: invalidateQueries on mutations (with triggerRefresh fallback) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
796
memento-note/components/note-editor/note-editor-context.tsx
Normal file
796
memento-note/components/note-editor/note-editor-context.tsx
Normal file
@@ -0,0 +1,796 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react'
|
||||
import { Note, CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, NoteType, 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'
|
||||
import { useNoteRefresh } from '@/context/NoteRefreshContext'
|
||||
import { useAutoTagging } from '@/hooks/use-auto-tagging'
|
||||
import { useTitleSuggestions } from '@/hooks/use-title-suggestions'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { extractImagesFromHTML } from '@/lib/utils'
|
||||
import type { TitleSuggestion } from '@/hooks/use-title-suggestions'
|
||||
import type { TagSuggestion } from '@/lib/ai/types'
|
||||
import type { NoteEditorState, NoteEditorActions, NoteEditorContextValue } from './types'
|
||||
|
||||
const NoteEditorContext = createContext<NoteEditorContextValue | undefined>(undefined)
|
||||
|
||||
interface NoteEditorProviderProps {
|
||||
note: Note
|
||||
readOnly?: boolean
|
||||
fullPage?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function NoteEditorProvider({ note, readOnly = false, fullPage = false, children }: NoteEditorProviderProps) {
|
||||
const { data: session } = useSession()
|
||||
const { t } = useLanguage()
|
||||
const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks()
|
||||
const { triggerRefresh } = useNoteRefresh()
|
||||
|
||||
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(true)
|
||||
const [autoLabelingEnabled, setAutoLabelingEnabled] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.id) {
|
||||
getAISettings(session.user.id).then(settings => {
|
||||
setAiAssistantEnabled(settings.paragraphRefactor !== false)
|
||||
setAutoLabelingEnabled(settings.autoLabeling !== false)
|
||||
}).catch(err => console.error("Failed to fetch AI settings", err))
|
||||
}
|
||||
}, [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 || [])
|
||||
const [labels, setLabels] = useState<string[]>(note.labels || [])
|
||||
const [images, setImages] = useState<string[]>(note.images || [])
|
||||
const [links, setLinks] = useState<LinkMetadata[]>(note.links || [])
|
||||
const [newLabel, setNewLabel] = useState('')
|
||||
const [color, setColor] = useState(note.color)
|
||||
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 [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 || '')
|
||||
setContent(note.content)
|
||||
setCheckItems(note.checkItems || [])
|
||||
setLabels(note.labels || [])
|
||||
setImages(note.images || [])
|
||||
setLinks(note.links || [])
|
||||
setColor(note.color)
|
||||
setSize(note.size || 'small')
|
||||
setNoteType(note.type)
|
||||
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 { suggestions, isAnalyzing: isAnalyzingSuggestions } = useAutoTagging({
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
notebookId: note.notebookId,
|
||||
enabled: noteType !== 'checklist' && autoLabelingEnabled
|
||||
})
|
||||
|
||||
// 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
|
||||
reformulatedText: string
|
||||
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,
|
||||
})
|
||||
|
||||
// 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
|
||||
return !dismissedTags.includes(s.tag) && !existingLabelsLower.includes(s.tag.toLowerCase())
|
||||
})
|
||||
|
||||
const colorClasses = NOTE_COLORS[color as NoteColor] || NOTE_COLORS.default
|
||||
|
||||
const uploadImageFile = async (file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await fetch('/api/upload', { method: 'POST', body: formData })
|
||||
if (!response.ok) throw new Error('Upload failed')
|
||||
const data = await response.json()
|
||||
return data.url
|
||||
}
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => [...prev, url])
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
toast.error(t('notes.uploadFailed', { filename: file.name }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paste handler: upload clipboard images
|
||||
useEffect(() => {
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
if (noteType === 'richtext' && (e.target as HTMLElement)?.closest('.notion-editor')) return;
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (!file) continue
|
||||
try {
|
||||
const url = await uploadImageFile(file)
|
||||
setImages(prev => [...prev, url])
|
||||
} catch {
|
||||
toast.error(t('notes.uploadFailed', { filename: 'pasted image' }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener('paste', handlePaste, { capture: true })
|
||||
return () => document.removeEventListener('paste', handlePaste, { capture: true } as any)
|
||||
}, [t, noteType])
|
||||
|
||||
// Auto-grow textarea as content grows
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
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
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.max(el.scrollHeight, 280) + 'px'
|
||||
el.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [showMarkdownPreview])
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddLink = async () => {
|
||||
if (!linkUrl) return
|
||||
|
||||
setShowLinkDialog(false)
|
||||
|
||||
try {
|
||||
const metadata = await fetchLinkMetadata(linkUrl)
|
||||
if (metadata) {
|
||||
setLinks(prev => [...prev, metadata])
|
||||
toast.success(t('notes.linkAdded'))
|
||||
} else {
|
||||
toast.warning(t('notes.linkMetadataFailed'))
|
||||
setLinks(prev => [...prev, { url: linkUrl, title: linkUrl }])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add link:', error)
|
||||
toast.error(t('notes.linkAddFailed'))
|
||||
} finally {
|
||||
setLinkUrl('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveLink = (index: number) => {
|
||||
setLinks(links.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const allImages = useMemo(() => {
|
||||
const extracted = noteType === 'richtext' ? extractImagesFromHTML(content) : [];
|
||||
return Array.from(new Set([...images, ...extracted]));
|
||||
}, [images, content, noteType]);
|
||||
|
||||
const handleGenerateTitles = async () => {
|
||||
const fullContentForAI = [
|
||||
content,
|
||||
...links.map(l => `${l.title || ''} ${l.description || ''}`)
|
||||
]
|
||||
.join(' ')
|
||||
.trim()
|
||||
|
||||
const wordCount = fullContentForAI.split(/\s+/).filter(word => word.length > 0).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
toast.error(t('ai.titleGenerationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
|
||||
setIsGeneratingTitles(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/title-suggestions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: fullContentForAI }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || t('ai.titleGenerationError'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setTitleSuggestions(data.suggestions || [])
|
||||
toast.success(t('ai.titlesGenerated', { count: data.suggestions.length }))
|
||||
} catch (error: any) {
|
||||
console.error('Error generating titles:', error)
|
||||
toast.error(error.message || t('ai.titleGenerationFailed'))
|
||||
} finally {
|
||||
setIsGeneratingTitles(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectTitle = (title: string) => {
|
||||
setTitle(title)
|
||||
setTitleSuggestions([])
|
||||
toast.success(t('ai.titleApplied'))
|
||||
}
|
||||
|
||||
const handleReformulate = async (option: 'clarify' | 'shorten' | 'improve') => {
|
||||
const selectedText = window.getSelection()?.toString()
|
||||
|
||||
if (!selectedText && (!content || content.trim().length === 0)) {
|
||||
toast.error(t('ai.reformulationNoText'))
|
||||
return
|
||||
}
|
||||
|
||||
let textToReformulate: string
|
||||
if (selectedText && selectedText.trim().split(/\s+/).filter(word => word.length > 0).length >= 10) {
|
||||
textToReformulate = selectedText
|
||||
} else {
|
||||
textToReformulate = content
|
||||
if (selectedText) {
|
||||
toast.info(t('ai.reformulationSelectionTooShort'))
|
||||
}
|
||||
}
|
||||
|
||||
const wordCount = textToReformulate.trim().split(/\s+/).filter(word => word.length > 0).length
|
||||
|
||||
if (wordCount < 10) {
|
||||
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
|
||||
if (wordCount > 500) {
|
||||
toast.error(t('ai.reformulationMaxWords'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsReformulating(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
text: textToReformulate,
|
||||
option: option
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || t('ai.reformulationError'))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
setReformulationModal({
|
||||
originalText: data.originalText,
|
||||
reformulatedText: data.reformulatedText,
|
||||
option: data.option
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error reformulating:', error)
|
||||
toast.error(error.message || t('ai.reformulationFailed'))
|
||||
} finally {
|
||||
setIsReformulating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClarifyDirect = async () => {
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
if (!content || wordCount < 10) {
|
||||
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content, option: 'clarify' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Clarify error:', error)
|
||||
toast.error(t('notes.clarifyFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShortenDirect = async () => {
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
if (!content || wordCount < 10) {
|
||||
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content, option: 'shorten' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Shorten error:', error)
|
||||
toast.error(t('notes.shortenFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImproveDirect = async () => {
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
if (!content || wordCount < 10) {
|
||||
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/reformulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content, option: 'improve' })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
|
||||
setContent(data.reformulatedText || data.text)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
} catch (error) {
|
||||
console.error('Improve error:', error)
|
||||
toast.error(t('notes.improveFailed'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTransformMarkdown = async () => {
|
||||
const wordCount = content.split(/\s+/).filter(w => w.length > 0).length
|
||||
if (!content || wordCount < 10) {
|
||||
toast.error(t('ai.reformulationMinWords', { count: wordCount }))
|
||||
return
|
||||
}
|
||||
|
||||
if (wordCount > 500) {
|
||||
toast.error(t('ai.reformulationMaxWords'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsProcessingAI(true)
|
||||
try {
|
||||
const response = await fetch('/api/ai/transform-markdown', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: content })
|
||||
})
|
||||
const data = await response.json()
|
||||
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
|
||||
|
||||
setContent(data.transformedText)
|
||||
setNoteType('markdown')
|
||||
setShowMarkdownPreview(false)
|
||||
|
||||
toast.success(t('ai.transformSuccess'))
|
||||
} catch (error) {
|
||||
console.error('Transform to markdown error:', error)
|
||||
toast.error(t('ai.transformError'))
|
||||
} finally {
|
||||
setIsProcessingAI(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyRefactor = () => {
|
||||
if (!reformulationModal) return
|
||||
|
||||
const selectedText = window.getSelection()?.toString()
|
||||
if (selectedText) {
|
||||
setContent(reformulationModal.reformulatedText)
|
||||
} else {
|
||||
setContent(reformulationModal.reformulatedText)
|
||||
}
|
||||
|
||||
setReformulationModal(null)
|
||||
toast.success(t('ai.reformulationApplied'))
|
||||
}
|
||||
|
||||
const handleReminderSave = async (date: Date) => {
|
||||
if (date < new Date()) {
|
||||
toast.error(t('notes.reminderPastError'))
|
||||
return
|
||||
}
|
||||
setCurrentReminder(date)
|
||||
try {
|
||||
await updateNote(note.id, { reminder: date })
|
||||
toast.success(t('notes.reminderSet', { datetime: date.toLocaleString() }))
|
||||
} catch {
|
||||
toast.error(t('notebook.savingReminder'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveReminder = async () => {
|
||||
setCurrentReminder(null)
|
||||
try {
|
||||
await updateNote(note.id, { reminder: null })
|
||||
toast.success(t('notes.reminderRemoved'))
|
||||
} catch {
|
||||
toast.error(t('notebook.removingReminder'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await updateNote(note.id, {
|
||||
title: title.trim() || null,
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||
labels,
|
||||
images,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown: noteType === 'markdown',
|
||||
type: noteType,
|
||||
size,
|
||||
})
|
||||
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
}
|
||||
|
||||
await refreshLabels()
|
||||
triggerRefresh()
|
||||
|
||||
// Note: onClose is handled by the composition component
|
||||
} catch (error) {
|
||||
console.error('Failed to save note:', error)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckItem = (id: string) => {
|
||||
setCheckItems(items =>
|
||||
items.map(item =>
|
||||
item.id === id ? { ...item, checked: !item.checked } : item
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdateCheckItem = (id: string, text: string) => {
|
||||
setCheckItems(items =>
|
||||
items.map(item => (item.id === id ? { ...item, text } : item))
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddCheckItem = () => {
|
||||
setCheckItems([
|
||||
...checkItems,
|
||||
{ id: Date.now().toString(), text: '', checked: false },
|
||||
])
|
||||
}
|
||||
|
||||
const handleRemoveCheckItem = (id: string) => {
|
||||
setCheckItems(items => items.filter(item => item.id !== id))
|
||||
}
|
||||
|
||||
const handleSelectGhostTag = async (tag: string) => {
|
||||
const tagExists = labels.some(l => l.toLowerCase() === tag.toLowerCase())
|
||||
|
||||
if (!tagExists) {
|
||||
setLabels(prev => [...prev, tag])
|
||||
|
||||
const globalExists = globalLabels.some(l => l.name.toLowerCase() === tag.toLowerCase())
|
||||
if (!globalExists) {
|
||||
try {
|
||||
await addLabel(tag)
|
||||
} catch (err) {
|
||||
console.error('Error creating auto-label:', err)
|
||||
}
|
||||
}
|
||||
toast.success(t('ai.tagAdded', { tag }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissGhostTag = (tag: string) => {
|
||||
setDismissedTags(prev => [...prev, tag])
|
||||
}
|
||||
|
||||
const handleRemoveLabel = (label: string) => {
|
||||
setLabels(labels.filter(l => l !== label))
|
||||
}
|
||||
|
||||
const handleMakeCopy = async () => {
|
||||
try {
|
||||
const newNote = await createNote({
|
||||
title: `${title || t('notes.untitled')} (${t('notes.copy')})`,
|
||||
content: content,
|
||||
color: color,
|
||||
checkItems: checkItems,
|
||||
labels: labels,
|
||||
images: images,
|
||||
links: links,
|
||||
isMarkdown: noteType === 'markdown',
|
||||
type: noteType,
|
||||
size: size,
|
||||
})
|
||||
toast.success(t('notes.copySuccess'))
|
||||
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 () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await updateNote(note.id, {
|
||||
title: title.trim() || null,
|
||||
content: noteType !== 'checklist' ? content : '',
|
||||
checkItems: noteType === 'checklist' ? checkItems : null,
|
||||
labels,
|
||||
images,
|
||||
links,
|
||||
color,
|
||||
reminder: currentReminder,
|
||||
isMarkdown: noteType === 'markdown',
|
||||
type: noteType,
|
||||
size,
|
||||
})
|
||||
if (removedImageUrls.length > 0) {
|
||||
cleanupOrphanedImages(removedImageUrls, note.id).catch(() => {})
|
||||
}
|
||||
await refreshLabels()
|
||||
triggerRefresh()
|
||||
setIsDirty(false)
|
||||
toast.success('Note sauvegardée !')
|
||||
} catch (error) {
|
||||
console.error('Failed to save note:', error)
|
||||
toast.error('Erreur lors de la sauvegarde.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S / Cmd+S shortcut — save in place in fullPage mode
|
||||
useEffect(() => {
|
||||
if (!fullPage) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault()
|
||||
handleSaveInPlace()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handler)
|
||||
return () => document.removeEventListener('keydown', handler)
|
||||
}, [fullPage, isSaving])
|
||||
|
||||
// Build state object
|
||||
const state: NoteEditorState = useMemo(() => ({
|
||||
title,
|
||||
content,
|
||||
checkItems,
|
||||
labels,
|
||||
images,
|
||||
links,
|
||||
newLabel,
|
||||
color: color as NoteColor,
|
||||
size,
|
||||
noteType,
|
||||
showMarkdownPreview,
|
||||
removedImageUrls,
|
||||
isSaving,
|
||||
isDirty,
|
||||
isProcessingAI,
|
||||
aiOpen,
|
||||
infoOpen,
|
||||
isGeneratingTitles,
|
||||
titleSuggestions,
|
||||
dismissedTitleSuggestions,
|
||||
isReformulating,
|
||||
reformulationModal,
|
||||
previousContentForCopilot,
|
||||
showReminderDialog,
|
||||
currentReminder,
|
||||
showLinkDialog,
|
||||
linkUrl,
|
||||
comparisonNotes,
|
||||
fusionNotes,
|
||||
dismissedTags,
|
||||
filteredSuggestions,
|
||||
isAnalyzingSuggestions,
|
||||
isMarkdown,
|
||||
allImages,
|
||||
colorClasses,
|
||||
}), [
|
||||
title, content, checkItems, labels, images, links, newLabel, color, size, noteType,
|
||||
showMarkdownPreview, removedImageUrls, isSaving, isDirty, isProcessingAI, aiOpen, infoOpen,
|
||||
isGeneratingTitles, titleSuggestions, dismissedTitleSuggestions, isReformulating,
|
||||
reformulationModal, previousContentForCopilot, showReminderDialog, currentReminder,
|
||||
showLinkDialog, linkUrl, comparisonNotes, fusionNotes, dismissedTags, filteredSuggestions,
|
||||
isAnalyzingSuggestions, isMarkdown, allImages, colorClasses
|
||||
])
|
||||
|
||||
// Build actions object
|
||||
const actions: NoteEditorActions = useMemo(() => ({
|
||||
setTitle: (t) => { setTitle(t); setIsDirty(true); setDismissedTitleSuggestions(true) },
|
||||
setDismissedTitleSuggestions,
|
||||
setContent: (c) => { setContent(c); setIsDirty(true) },
|
||||
setCheckItems,
|
||||
handleCheckItem,
|
||||
handleUpdateCheckItem,
|
||||
handleAddCheckItem,
|
||||
handleRemoveCheckItem,
|
||||
setLabels: (l) => { setLabels(l); setIsDirty(true) },
|
||||
handleSelectGhostTag,
|
||||
handleDismissGhostTag,
|
||||
handleRemoveLabel,
|
||||
setImages,
|
||||
handleImageUpload,
|
||||
handleRemoveImage,
|
||||
uploadImageFile,
|
||||
setLinks,
|
||||
handleAddLink,
|
||||
handleRemoveLink,
|
||||
setNoteType: (type) => { setNoteType(type); setShowMarkdownPreview(type === 'markdown'); setIsDirty(true) },
|
||||
setShowMarkdownPreview: (show) => { setShowMarkdownPreview(show); setIsDirty(true) },
|
||||
setColor: (c) => { setColor(c); setIsDirty(true) },
|
||||
setSize: (s) => { setSize(s); setIsDirty(true) },
|
||||
setShowReminderDialog,
|
||||
setCurrentReminder,
|
||||
handleReminderSave,
|
||||
handleRemoveReminder,
|
||||
setShowLinkDialog,
|
||||
setLinkUrl,
|
||||
handleGenerateTitles,
|
||||
handleSelectTitle,
|
||||
handleReformulate,
|
||||
handleApplyRefactor,
|
||||
handleClarifyDirect,
|
||||
handleShortenDirect,
|
||||
handleImproveDirect,
|
||||
handleTransformMarkdown,
|
||||
handleSave,
|
||||
handleSaveInPlace,
|
||||
handleMakeCopy,
|
||||
setComparisonNotes,
|
||||
setFusionNotes,
|
||||
setReformulationModal,
|
||||
setIsDirty,
|
||||
setAiOpen,
|
||||
setInfoOpen,
|
||||
setIsProcessingAI,
|
||||
setIsGeneratingTitles,
|
||||
setIsAnalyzingSuggestions: (a) => { /* handled by useAutoTagging */ },
|
||||
setPreviousContentForCopilot,
|
||||
}), [])
|
||||
|
||||
const value: NoteEditorContextValue = useMemo(() => ({
|
||||
note,
|
||||
readOnly,
|
||||
fullPage,
|
||||
state,
|
||||
actions,
|
||||
notebooks: notebooks.map(nb => ({ id: nb.id, name: nb.name })),
|
||||
globalLabels,
|
||||
fileInputRef,
|
||||
textareaRef,
|
||||
}), [note, readOnly, fullPage, state, actions, notebooks, globalLabels])
|
||||
|
||||
return (
|
||||
<NoteEditorContext.Provider value={value}>
|
||||
{children}
|
||||
</NoteEditorContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useNoteEditorContext() {
|
||||
const context = useContext(NoteEditorContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useNoteEditorContext must be used within a NoteEditorProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
Reference in New Issue
Block a user