'use client' import { createContext, useContext, useState, useEffect, useRef, useMemo, useCallback, ReactNode } from 'react' import { useQueryClient } from '@tanstack/react-query' 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' import { emitNoteChange, NOTE_REQUEST_SAVE_EVENT } from '@/lib/note-change-sync' import { useAutoTagging } from '@/hooks/use-auto-tagging' import { useTitleSuggestions } from '@/hooks/use-title-suggestions' import { toast } from 'sonner' import { useLanguage } from '@/lib/i18n' import { useAiConsent } from '@/components/legal/ai-consent-provider' import { useSession } from 'next-auth/react' import { getAISettings } from '@/app/actions/ai-settings' import { extractImagesFromHTML } from '@/lib/utils' import { queryKeys } from '@/lib/query-keys' import type { RichTextEditorHandle } from '@/components/rich-text-editor' 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(undefined) interface NoteEditorProviderProps { note: Note readOnly?: boolean fullPage?: boolean onNoteSaved?: (savedNote: Note) => void children: ReactNode } export function NoteEditorProvider({ note, readOnly = false, fullPage = false, onNoteSaved, children }: NoteEditorProviderProps) { const { data: session } = useSession() const { t } = useLanguage() const { requestAiConsent } = useAiConsent() const queryClient = useQueryClient() const { labels: globalLabels, addLabel, refreshLabels, setNotebookId: setContextNotebookId, notebooks } = useNotebooks() 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]) const [title, setTitle] = useState(note.title || '') const [content, setContent] = useState(note.content) const [checkItems, setCheckItems] = useState(note.checkItems || []) const [labels, setLabels] = useState(note.labels || []) const [images, setImages] = useState(note.images || []) const [links, setLinks] = useState(note.links || []) const [newLabel, setNewLabel] = useState('') const [color, setColor] = useState(note.color) const [size, setSize] = useState(note.size || 'small') const [isSaving, setIsSaving] = useState(false) const [removedImageUrls, setRemovedImageUrls] = useState([]) const [isMarkdown, setIsMarkdown] = useState(note.type === 'markdown') const [showMarkdownPreview, setShowMarkdownPreview] = useState(note.type === 'markdown') const fileInputRef = useRef(null) const textareaRef = useRef(null) const richTextEditorRef = useRef(null) const prevNoteRef = useRef(note) useEffect(() => { const prev = prevNoteRef.current if (note.id !== prev.id) { 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') setIsMarkdown(note.type === 'markdown') setShowMarkdownPreview(note.type === 'markdown') setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null) } else { if (note.title !== prev.title) setTitle(note.title || '') // Ne pas réinitialiser le contenu quand seuls images/links changent (post-save) if (note.content !== prev.content) setContent(note.content) if (JSON.stringify(note.checkItems || []) !== JSON.stringify(prev.checkItems || [])) { setCheckItems(note.checkItems || []) } if (JSON.stringify(note.labels || []) !== JSON.stringify(prev.labels || [])) { setLabels(note.labels || []) } if (JSON.stringify(note.images || []) !== JSON.stringify(prev.images || [])) { setImages(note.images || []) } if (JSON.stringify(note.links || []) !== JSON.stringify(prev.links || [])) { setLinks(note.links || []) } if (note.color !== prev.color) setColor(note.color) if ((note.size || 'small') !== (prev.size || 'small')) setSize(note.size || 'small') const noteIsMarkdown = note.type === 'markdown' const prevIsMarkdown = prev.type === 'markdown' if (noteIsMarkdown !== prevIsMarkdown) { setIsMarkdown(noteIsMarkdown) setShowMarkdownPreview(noteIsMarkdown) } const prevReminder = prev.reminder ? new Date(prev.reminder as unknown as string).getTime() : null const nextReminder = note.reminder ? new Date(note.reminder as unknown as string).getTime() : null if (prevReminder !== nextReminder) { setCurrentReminder(note.reminder ? new Date(note.reminder as unknown as string) : null) } } prevNoteRef.current = note }, [note]) useEffect(() => { setContextNotebookId(note.notebookId || null) }, [note.notebookId, setContextNotebookId]) const [dismissedTags, setDismissedTags] = useState([]) 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: content, notebookId: note.notebookId, enabled: autoTaggingEnabled }) const [showReminderDialog, setShowReminderDialog] = useState(false) const [currentReminder, setCurrentReminder] = useState( note.reminder ? new Date(note.reminder as unknown as string) : null ) const [showLinkDialog, setShowLinkDialog] = useState(false) const [linkUrl, setLinkUrl] = useState('') const [titleSuggestions, setTitleSuggestions] = useState([]) const [isGeneratingTitles, setIsGeneratingTitles] = useState(false) const [isReformulating, setIsReformulating] = useState(false) const [reformulationModal, setReformulationModal] = useState<{ originalText: string reformulatedText: string option: string } | null>(null) const [isProcessingAI, setIsProcessingAI] = useState(false) const [aiOpen, setAiOpen] = useState(false) const [infoOpen, setInfoOpen] = useState(false) const [isDirty, setIsDirty] = useState(false) const [dismissedTitleSuggestions, setDismissedTitleSuggestions] = useState(false) const { suggestions: autoTitleSuggestions } = useTitleSuggestions({ content, enabled: fullPage && !title && !dismissedTitleSuggestions, }) useEffect(() => { if (autoTitleSuggestions.length > 0) { setTitleSuggestions(autoTitleSuggestions) } }, [autoTitleSuggestions]) const [previousContentForCopilot, setPreviousContentForCopilot] = useState(null) const [comparisonNotes, setComparisonNotes] = useState>>([]) const [fusionNotes, setFusionNotes] = useState>>([]) 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) => { const files = e.target.files if (!files) return for (const file of Array.from(files)) { try { const url = await uploadImageFile(file) setImages(prev => prev.includes(url) ? prev : [...prev, url]) setIsDirty(true) } catch (error) { console.error('Upload error:', error) toast.error(t('notes.uploadFailed', { filename: file.name })) } } } useEffect(() => { const handlePaste = async (e: ClipboardEvent) => { 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)) { if (item.type.startsWith('image/')) { e.preventDefault() const file = item.getAsFile() if (!file) continue try { const url = await uploadImageFile(file) setImages(prev => prev.includes(url) ? prev : [...prev, url]) setIsDirty(true) } 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, isMarkdown]) useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' el.style.height = Math.max(el.scrollHeight, 280) + 'px' }, [content]) useEffect(() => { if (showMarkdownPreview) return 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)) if (removedUrl) { setRemovedImageUrls(prev => [...prev, removedUrl]) } setIsDirty(true) } 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 = !isMarkdown ? extractImagesFromHTML(content) : [] return Array.from(new Set([...images, ...extracted])) }, [images, content, isMarkdown]) const resolveContentForSave = useCallback((): string => { if (!isMarkdown) { const editor = richTextEditorRef.current?.getEditor() if (editor) return editor.getHTML() } return content }, [content, isMarkdown]) const resolveImagesForSave = useCallback((contentToSave: string): string[] => { if (!contentToSave) return [] if (!isMarkdown) { return extractImagesFromHTML(contentToSave) } else { const urls = new Set() const matches = contentToSave.matchAll(/!\[.*?\]\((.*?)\)/g) for (const match of matches) { const src = match[1]?.trim() if (src) urls.add(src) } return Array.from(urls) } }, [isMarkdown]) 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 } const consented = await requestAiConsent() if (!consented) 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 || []) if (!fullPage && data.suggestions?.[0]?.title) { setTitle(data.suggestions[0].title) setDismissedTitleSuggestions(true) toast.success(t('ai.titlesGenerated', { count: data.suggestions.length })) } else if (data.suggestions?.length) { 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 } const consented = await requestAiConsent() if (!consented) 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 } const consented = await requestAiConsent() if (!consented) 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 } const consented = await requestAiConsent() if (!consented) 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 } const consented = await requestAiConsent() if (!consented) 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 } const consented = await requestAiConsent() if (!consented) 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) setIsMarkdown(true) 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 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 }, { skipRevalidation: true }) 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 }, { skipRevalidation: true }) toast.success(t('notes.reminderRemoved')) } catch { toast.error(t('notebook.removingReminder')) } } const handleSave = async () => { setIsSaving(true) try { const contentToSave = resolveContentForSave() const imagesToSave = resolveImagesForSave(contentToSave) const result = await updateNote(note.id, { title: title.trim() || null, content: contentToSave, checkItems: null, labels, images: imagesToSave, links, color, reminder: currentReminder, isMarkdown, type: isMarkdown ? 'markdown' as const : 'richtext' as const, size, }, { skipRevalidation: true }) if (contentToSave !== content) setContent(contentToSave) if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave) prevNoteRef.current = { ...prevNoteRef.current, ...result } const deletedImages = Array.from(new Set([ ...removedImageUrls, ...images.filter(url => !imagesToSave.includes(url)) ])) if (deletedImages.length > 0) { cleanupOrphanedImages(deletedImages, note.id).catch(() => {}) setRemovedImageUrls([]) } await refreshLabels() onNoteSaved?.(result) queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) }) queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) }) emitNoteChange({ type: 'updated', note: result }) toast.success(t('notes.saved') || 'Note sauvegardée !') } catch (error) { console.error('[SAVE] updateNote failed:', error) toast.error(t('notes.saveFailed') || 'Erreur lors de la sauvegarde.') } 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]) setIsDirty(true) 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 => { 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 () => { 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, type: isMarkdown ? 'markdown' : 'richtext', size: size, }) toast.success(t('notes.copySuccess')) queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) }) emitNoteChange({ type: 'created', note: newNote }) } catch (error) { console.error('Failed to copy note:', error) toast.error(t('notes.copyFailed')) } } const handleSaveInPlace = async () => { setIsSaving(true) try { const contentToSave = resolveContentForSave() const imagesToSave = resolveImagesForSave(contentToSave) const updatePayload = { title: title.trim() || null, content: contentToSave, checkItems: null, labels, images: imagesToSave, links, color, reminder: currentReminder, isMarkdown, type: isMarkdown ? 'markdown' as const : 'richtext' as const, size, } const result = await updateNote(note.id, updatePayload, { skipRevalidation: true }) if (contentToSave !== content) setContent(contentToSave) if (JSON.stringify(imagesToSave) !== JSON.stringify(images)) setImages(imagesToSave) prevNoteRef.current = { ...prevNoteRef.current, ...result } const deletedImages = Array.from(new Set([ ...removedImageUrls, ...images.filter(url => !imagesToSave.includes(url)) ])) if (deletedImages.length > 0) { cleanupOrphanedImages(deletedImages, note.id).catch(() => {}) setRemovedImageUrls([]) } await refreshLabels() onNoteSaved?.(result) queryClient.invalidateQueries({ queryKey: queryKeys.note(note.id) }) queryClient.invalidateQueries({ queryKey: queryKeys.notes(note.notebookId) }) emitNoteChange({ type: 'updated', note: result }) setIsDirty(false) toast.success(t('notes.saved') || 'Saved') } catch (error) { console.error('[SAVE] updateNote failed:', error) toast.error(t('notes.saveFailed') || 'Save failed') } finally { setIsSaving(false) } } const handleSaveInPlaceRef = useRef(handleSaveInPlace) handleSaveInPlaceRef.current = handleSaveInPlace const handleSaveRef = useRef(handleSave) handleSaveRef.current = handleSave useEffect(() => { if (!fullPage) return const handler = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault() void handleSaveInPlaceRef.current() } } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) }, [fullPage]) useEffect(() => { const onRequestSave = (event: Event) => { const detail = (event as CustomEvent<{ noteId?: string }>).detail if (detail?.noteId !== note.id || readOnly) return if (fullPage) { void handleSaveInPlaceRef.current() } else { void handleSaveRef.current() } } window.addEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave) return () => window.removeEventListener(NOTE_REQUEST_SAVE_EVENT, onRequestSave) }, [note.id, fullPage, readOnly]) const state: NoteEditorState = useMemo(() => ({ title, content, checkItems, labels, images, links, newLabel, color: color as NoteColor, size, 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, 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 ]) const actions: NoteEditorActions = { 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, 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, 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, parentId: nb.parentId, trashedAt: nb.trashedAt })), globalLabels, fileInputRef, textareaRef, richTextEditorRef, }), [note, readOnly, fullPage, state, actions, notebooks, globalLabels]) return ( {children} ) } export function useNoteEditorContext() { const context = useContext(NoteEditorContext) if (context === undefined) { throw new Error('useNoteEditorContext must be used within a NoteEditorProvider') } return context }