|
| 1 | +import { useState, useEffect, useRef, useCallback } from 'react'; |
| 2 | +import { FiEdit2, FiSave, FiX } from 'react-icons/fi'; |
| 3 | +import { useNotes } from '../../context/NoteContext'; |
| 4 | + |
| 5 | +export default function NoteEditor() { |
| 6 | + const { activeNote, updateNoteContent, updateNoteTitle } = useNotes(); |
| 7 | + const [content, setContent] = useState(''); |
| 8 | + const [isEditingTitle, setIsEditingTitle] = useState(false); |
| 9 | + const [title, setTitle] = useState(''); |
| 10 | + const titleInputRef = useRef(null); |
| 11 | + const textareaRef = useRef(null); |
| 12 | + |
| 13 | + // Update local state when active note changes |
| 14 | + useEffect(() => { |
| 15 | + if (activeNote) { |
| 16 | + setContent(activeNote.content || ''); |
| 17 | + setTitle(activeNote.title || 'Untitled Note'); |
| 18 | + } |
| 19 | + }, [activeNote]); |
| 20 | + |
| 21 | + // Focus the editor when active note changes |
| 22 | + useEffect(() => { |
| 23 | + if (activeNote && textareaRef.current) { |
| 24 | + textareaRef.current.focus(); |
| 25 | + } |
| 26 | + }, [activeNote]); |
| 27 | + |
| 28 | + // Handle content changes with debounce |
| 29 | + useEffect(() => { |
| 30 | + if (!activeNote) return; |
| 31 | + |
| 32 | + const timer = setTimeout(() => { |
| 33 | + if (content !== activeNote.content) { |
| 34 | + updateNoteContent(activeNote.id, content); |
| 35 | + } |
| 36 | + }, 300); |
| 37 | + |
| 38 | + return () => clearTimeout(timer); |
| 39 | + }, [content, activeNote, updateNoteContent]); |
| 40 | + |
| 41 | + // Handle title update |
| 42 | + const handleTitleUpdate = useCallback(() => { |
| 43 | + if (!activeNote) return; |
| 44 | + |
| 45 | + const newTitle = title.trim() || 'Untitled Note'; |
| 46 | + updateNoteTitle(activeNote.id, newTitle); |
| 47 | + setIsEditingTitle(false); |
| 48 | + }, [activeNote, title, updateNoteTitle]); |
| 49 | + |
| 50 | + // Handle keyboard shortcuts |
| 51 | + const handleKeyDown = useCallback((e) => { |
| 52 | + // Save: Ctrl+S or Cmd+S |
| 53 | + if ((e.ctrlKey || e.metaKey) && e.key === 's') { |
| 54 | + e.preventDefault(); |
| 55 | + // Show save indicator |
| 56 | + const saveBtn = document.querySelector('[aria-label="Save note"]'); |
| 57 | + if (saveBtn) { |
| 58 | + saveBtn.classList.add('animate-ping'); |
| 59 | + setTimeout(() => saveBtn.classList.remove('animate-ping'), 1000); |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + // New line with Shift+Enter |
| 64 | + if (e.key === 'Enter' && e.shiftKey) { |
| 65 | + e.preventDefault(); |
| 66 | + const start = e.target.selectionStart; |
| 67 | + const end = e.target.selectionEnd; |
| 68 | + const newValue = content.substring(0, start) + '\n' + content.substring(end); |
| 69 | + setContent(newValue); |
| 70 | + |
| 71 | + // Move cursor to the new line |
| 72 | + setTimeout(() => { |
| 73 | + textareaRef.current.selectionStart = start + 1; |
| 74 | + textareaRef.current.selectionEnd = start + 1; |
| 75 | + }, 0); |
| 76 | + } |
| 77 | + }, [content]); |
| 78 | + |
| 79 | + if (!activeNote) { |
| 80 | + return ( |
| 81 | + <div className="flex-1 flex items-center justify-center bg-white dark:bg-gray-900 text-gray-500 dark:text-gray-400"> |
| 82 | + <p>Select a note or create a new one</p> |
| 83 | + </div> |
| 84 | + ); |
| 85 | + } |
| 86 | + |
| 87 | + return ( |
| 88 | + <div className="flex-1 flex flex-col border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900"> |
| 89 | + {/* Editor header */} |
| 90 | + <div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700"> |
| 91 | + <div className="flex items-center justify-between"> |
| 92 | + <div className="flex-1 min-w-0"> |
| 93 | + {isEditingTitle ? ( |
| 94 | + <div className="flex items-center"> |
| 95 | + <input |
| 96 | + ref={titleInputRef} |
| 97 | + type="text" |
| 98 | + className="text-xl font-semibold bg-transparent border-b border-blue-500 focus:outline-none focus:ring-0 w-full text-gray-900 dark:text-white" |
| 99 | + value={title} |
| 100 | + onChange={(e) => setTitle(e.target.value)} |
| 101 | + onBlur={handleTitleUpdate} |
| 102 | + onKeyDown={(e) => { |
| 103 | + if (e.key === 'Enter') { |
| 104 | + e.preventDefault(); |
| 105 | + handleTitleUpdate(); |
| 106 | + } else if (e.key === 'Escape') { |
| 107 | + setIsEditingTitle(false); |
| 108 | + setTitle(activeNote.title); |
| 109 | + } |
| 110 | + }} |
| 111 | + autoFocus |
| 112 | + /> |
| 113 | + <button |
| 114 | + onClick={() => { |
| 115 | + setIsEditingTitle(false); |
| 116 | + setTitle(activeNote.title); |
| 117 | + }} |
| 118 | + className="ml-2 p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors" |
| 119 | + aria-label="Cancel editing" |
| 120 | + > |
| 121 | + <FiX className="h-4 w-4" /> |
| 122 | + </button> |
| 123 | + </div> |
| 124 | + ) : ( |
| 125 | + <div className="flex items-center group"> |
| 126 | + <h2 |
| 127 | + className="text-xl font-semibold text-gray-900 dark:text-white cursor-text" |
| 128 | + onClick={() => setIsEditingTitle(true)} |
| 129 | + > |
| 130 | + {activeNote.title} |
| 131 | + </h2> |
| 132 | + <button |
| 133 | + onClick={() => { |
| 134 | + setIsEditingTitle(true); |
| 135 | + setTimeout(() => titleInputRef.current?.focus(), 0); |
| 136 | + }} |
| 137 | + className="ml-2 p-1 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 rounded-full hover:bg-gray-200 dark:hover:bg-gray-600 transition-all" |
| 138 | + aria-label="Edit title" |
| 139 | + > |
| 140 | + <FiEdit2 className="h-4 w-4" /> |
| 141 | + </button> |
| 142 | + </div> |
| 143 | + )} |
| 144 | + <p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> |
| 145 | + Last updated: {new Date(activeNote.updatedAt).toLocaleString()} |
| 146 | + </p> |
| 147 | + </div> |
| 148 | + <div className="ml-4 flex items-center"> |
| 149 | + <button |
| 150 | + onClick={() => { |
| 151 | + // Export as Markdown |
| 152 | + const blob = new Blob([activeNote.content], { type: 'text/markdown' }); |
| 153 | + const url = URL.createObjectURL(blob); |
| 154 | + const a = document.createElement('a'); |
| 155 | + a.href = url; |
| 156 | + a.download = `${activeNote.title.replace(/\s+/g, '_').toLowerCase()}.md`; |
| 157 | + document.body.appendChild(a); |
| 158 | + a.click(); |
| 159 | + document.body.removeChild(a); |
| 160 | + URL.revokeObjectURL(url); |
| 161 | + }} |
| 162 | + className="inline-flex items-center px-3 py-2 border border-gray-300 dark:border-gray-600 text-sm leading-4 font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors" |
| 163 | + title="Export as Markdown" |
| 164 | + aria-label="Export note as Markdown file" |
| 165 | + > |
| 166 | + <FiSave className="mr-2 h-4 w-4" /> |
| 167 | + Export |
| 168 | + </button> |
| 169 | + </div> |
| 170 | + </div> |
| 171 | + </div> |
| 172 | + |
| 173 | + {/* Editor content */} |
| 174 | + <div className="flex-1 flex flex-col"> |
| 175 | + <div className="px-4 py-2 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> |
| 176 | + <span className="text-xs font-medium text-gray-500 dark:text-gray-400">EDITOR</span> |
| 177 | + {activeNote && ( |
| 178 | + <span className="text-xs text-gray-500 dark:text-gray-400"> |
| 179 | + {content.length} characters • {content.split(/\s+/).filter(Boolean).length} words |
| 180 | + </span> |
| 181 | + )} |
| 182 | + </div> |
| 183 | + <div className="flex-1 overflow-auto"> |
| 184 | + <textarea |
| 185 | + ref={textareaRef} |
| 186 | + className="w-full h-full p-4 text-gray-900 dark:text-white bg-white dark:bg-gray-900 focus:outline-none resize-none focus:ring-2 focus:ring-blue-500 font-mono text-sm leading-relaxed" |
| 187 | + value={content} |
| 188 | + onChange={(e) => setContent(e.target.value)} |
| 189 | + onKeyDown={handleKeyDown} |
| 190 | + placeholder="Start writing your markdown here..." |
| 191 | + spellCheck="false" |
| 192 | + aria-label="Note content" |
| 193 | + /> |
| 194 | + </div> |
| 195 | + </div> |
| 196 | + </div> |
| 197 | + ); |
| 198 | +} |
0 commit comments