Editor.Core Component
The Editor.Core component is the textarea input wrapper that handles all user text input, selection tracking, and keyboard interactions. It's the heart of Nindo's editing experience.
Import
tsximport { Editor } from '@/components/ui/markdown-editor'; // Use as <Editor.Core />
Overview
Editor.Core wraps a native HTML <textarea> element with:
- Content synchronization
- Selection/cursor tracking
- Keyboard event handling
- Styling and placeholder support
Why textarea over contentEditable ?
- Predictable cursor behavior across all browsers
- Native undo/redo support
- No DOM manipulation bugs
- Better accessibility
- Simpler state management
Props
content
- Type:
string - Required: Yes
- Description: The current markdown content to display in the textarea
tsx<Editor.Core content="# Hello World" onChange={handleChange} onSelectionChange={handleSelection} textareaRef={textareaRef} />
onChange
- Type:
(content: string) => void - Required: Yes
- Description: Callback fired when the textarea content changes
tsxconst [markdown, setMarkdown] = useState(''); <Editor.Core content={markdown} onChange={setMarkdown} onSelectionChange={handleSelection} textareaRef={textareaRef} />
When is it called?
- User types or deletes text
- User pastes content
- User cuts content
- Content is changed programmatically
onSelectionChange
- Type:
(selection: SelectionState) => void - Required: Yes
- Description: Callback fired when the cursor position or text selection changes
tsxinterface SelectionState { start: number; // Starting position of selection end: number; // Ending position of selection } const handleSelectionChange = (selection: SelectionState) => { console.log('Cursor at:', selection.start); console.log('Selection length:', selection.end - selection.start); }; <Editor.Core content={content} onChange={handleChange} onSelectionChange={handleSelectionChange} textareaRef={textareaRef} />
When is it called?
- User clicks to move cursor
- User selects text with mouse or keyboard
- User presses arrow keys
- Text selection changes after typing
textareaRef
- Type:
React.RefObject<HTMLTextAreaElement | null> - Required: Yes
- Description: React ref to the underlying textarea element
tsxconst textareaRef = useRef<HTMLTextAreaElement>(null); <Editor.Core content={content} onChange={handleChange} onSelectionChange={handleSelection} textareaRef={textareaRef} /> // Access the textarea DOM element textareaRef.current?.focus(); textareaRef.current?.setSelectionRange(0, 10);
Common use cases:
- Programmatically focus the textarea
- Set cursor position after formatting
- Scroll to specific position
- Get textarea dimensions
placeholder
- Type:
string - Optional: Yes
- Default:
"Start writing your markdown..." - Description: Placeholder text shown when textarea is empty
tsx<Editor.Core content={content} onChange={handleChange} onSelectionChange={handleSelection} textareaRef={textareaRef} placeholder="Write your blog post here..." />
className
- Type:
string - Optional: Yes
- Description: Additional CSS classes for the wrapper div
tsx<Editor.Core content={content} onChange={handleChange} onSelectionChange={handleSelection} textareaRef={textareaRef} className="border-l-4 border-blue-500" />
Basic Usage
The following examples will handle everything manually just to know how it works. But if you want something more clean and easier consider coupling Editor.Core with useEditor hook.
Standalone Editor
tsx'use client'; import { Editor } from '@/components/ui/markdown-editor'; import { useState, useRef } from 'react'; export default function StandaloneEditorCore() { const [content, setContent] = useState(''); const [selection, setSelection] = useState({ start: 0, end: 0 }); const textareaRef = useRef<HTMLTextAreaElement>(null); return ( <div className="h-auto"> <Editor.Core content={content} onChange={setContent} onSelectionChange={setSelection} textareaRef={textareaRef} placeholder="Start writing..." /> <div className="p-4 border-t flex items-center gap-4 text-sm"> <p>Characters: {content.length}</p> <p>Cursor at: {selection.start}</p> </div> </div> ); }
Preview
Characters: 0
Cursor at: 0
With Custom Styling
tsx<Editor.Core content={content} onChange={setContent} onSelectionChange={setSelection} textareaRef={textareaRef} className="bg-slate-50 dark:bg-slate-900" />
Programmatic Focus
tsx'use client'; import { Editor } from '@/components/ui/markdown-editor'; import { Button } from '@/components/ui/button'; import { useRef, useState } from 'react'; export default function FocusableEditor() { const [content, setContent] = useState(''); const textareaRef = useRef<HTMLTextAreaElement>(null); const focusEditor = () => { textareaRef.current?.focus(); // Optionally, move cursor to end const length = content.length; textareaRef.current?.setSelectionRange(length, length); }; return ( <div className="h-screen flex flex-col gap-4 p-4"> <Button onClick={focusEditor}> Focus Editor </Button> <Editor.Core content={content} onChange={setContent} onSelectionChange={() => {}} textareaRef={textareaRef} /> </div> ); }
Preview
Insert Text at Cursor
tsx'use client'; import { Editor } from '@/components/ui/markdown-editor'; import { Button } from '@/components/ui/button'; import { useRef, useState } from 'react'; export default function InsertTextEditor() { const [content, setContent] = useState(''); const [selection, setSelection] = useState({ start: 0, end: 0 }); const textareaRef = useRef<HTMLTextAreaElement>(null); const insertText = (text: string) => { const { start, end } = selection; const before = content.slice(0, start); const after = content.slice(end); const newContent = before + text + after; setContent(newContent); // Move cursor after inserted text setTimeout(() => { const newPosition = start + text.length; textareaRef.current?.setSelectionRange(newPosition, newPosition); textareaRef.current?.focus(); }, 0); }; return ( <div className="h-screen flex flex-col gap-4 p-4"> <div className="flex gap-2"> <Button onClick={() => insertText('**bold text**')}> Insert Bold </Button> <Button onClick={() => insertText('[link](url)')}> Insert Link </Button> <Button onClick={() => insertText('```\ncode\n```')}> Insert Code </Button> </div> <Editor.Core content={content} onChange={setContent} onSelectionChange={setSelection} textareaRef={textareaRef} /> </div> ); }
Preview
With Character Counter
tsx'use client'; import { Editor } from '@/components/ui/markdown-editor'; import { useState, useRef } from 'react'; export default function CountedEditor() { const [content, setContent] = useState(''); const textareaRef = useRef<HTMLTextAreaElement>(null); const MAX_LENGTH = 5000; const remaining = MAX_LENGTH - content.length; const handleChange = (newContent: string) => { if (newContent.length <= MAX_LENGTH) { setContent(newContent); } }; return ( <div className="h-screen flex flex-col"> <div className="border-b p-2 text-sm"> <span className={remaining < 100 ? 'text-red-500' : 'text-muted-foreground'}> {remaining} / {MAX_LENGTH} </span> </div> <Editor.Core content={content} onChange={handleChange} onSelectionChange={() => {}} textareaRef={textareaRef} /> </div> ); }
Preview
Read-Only Mode
tsx<Editor.Core content={content} onChange={() => {}} // No-op onSelectionChange={() => {}} textareaRef={textareaRef} className="pointer-events-none opacity-60" />
Selection Tracking
The onSelectionChange callback provides detailed cursor/selection information:
tsxconst handleSelectionChange = (selection: SelectionState) => { const { start, end } = selection; // Cursor position (when start === end) if (start === end) { console.log(`Cursor at position ${start}`); } // Text selection else { const selectedText = content.slice(start, end); console.log(`Selected: "${selectedText}"`); console.log(`Length: ${end - start} characters`); } }; <Editor.Core content={content} onChange={setContent} onSelectionChange={handleSelectionChange} textareaRef={textareaRef} />
Common Use Cases
Auto-Save Implementation
tsx'use client'; import { Editor } from '@/components/ui/markdown-editor'; import { useState, useEffect, useRef } from 'react'; export default function AutoSaveEditor() { const [content, setContent] = useState(''); const [isSaving, setIsSaving] = useState(false); const textareaRef = useRef<HTMLTextAreaElement>(null); useEffect(() => { const timer = setTimeout(async () => { if (!content) return; setIsSaving(true); try { await fetch('/api/save', { method: 'POST', body: JSON.stringify({ content }) }); } finally { setIsSaving(false); } }, 2000); // Auto-save after 2 seconds of inactivity return () => clearTimeout(timer); }, [content]); return ( <div className="h-screen flex flex-col"> <div className="border-b p-2 text-sm text-muted-foreground"> {isSaving ? 'Saving...' : 'All changes saved'} </div> <Editor.Core content={content} onChange={setContent} onSelectionChange={() => {}} textareaRef={textareaRef} /> </div> ); }
Undo/Redo Implementation
tsx'use client'; import { Editor } from '@/components/ui/markdown-editor'; import { Button } from '@/components/ui/button'; import { useState, useRef } from 'react'; export default function UndoEditor() { const [content, setContent] = useState(''); const [history, setHistory] = useState(['']); const [historyIndex, setHistoryIndex] = useState(0); const textareaRef = useRef<HTMLTextAreaElement>(null); const handleChange = (newContent: string) => { setContent(newContent); setHistory(prev => [...prev.slice(0, historyIndex + 1), newContent]); setHistoryIndex(prev => prev + 1); }; const undo = () => { if (historyIndex > 0) { setHistoryIndex(prev => prev - 1); setContent(history[historyIndex - 1]); } }; const redo = () => { if (historyIndex < history.length - 1) { setHistoryIndex(prev => prev + 1); setContent(history[historyIndex + 1]); } }; return ( <div className="h-screen flex flex-col gap-4 p-4"> <div className="flex gap-2"> <Button onClick={undo} disabled={historyIndex === 0}> Undo </Button> <Button onClick={redo} disabled={historyIndex === history.length - 1}> Redo </Button> </div> <Editor.Core content={content} onChange={handleChange} onSelectionChange={() => {}} textareaRef={textareaRef} /> </div> ); }
Preview
Find and Replace
tsx'use client'; import { Editor } from '@/components/ui/markdown-editor'; import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { useState, useRef } from 'react'; export default function FindReplaceEditor() { const [content, setContent] = useState(''); const [findText, setFindText] = useState(''); const [replaceText, setReplaceText] = useState(''); const textareaRef = useRef<HTMLTextAreaElement>(null); const handleReplace = () => { const newContent = content.replace(new RegExp(findText, 'g'), replaceText); setContent(newContent); }; const highlightNext = () => { const index = content.indexOf(findText); if (index !== -1) { textareaRef.current?.setSelectionRange(index, index + findText.length); textareaRef.current?.focus(); } }; return ( <div className="h-screen flex flex-col gap-4 p-4"> <div className="flex gap-2"> <Input placeholder="Find..." value={findText} onChange={(e) => setFindText(e.target.value)} /> <Input placeholder="Replace with..." value={replaceText} onChange={(e) => setReplaceText(e.target.value)} /> <Button onClick={highlightNext}>Find</Button> <Button onClick={handleReplace}>Replace All</Button> </div> <Editor.Core content={content} onChange={setContent} onSelectionChange={() => {}} textareaRef={textareaRef} /> </div> ); }
Preview
Accessibility
The Editor.Core component includes:
- ✅ Native textarea semantics (accessible by default)
- ✅ Keyboard navigation (arrow keys, Tab, etc.)
- ✅ Screen reader support
- ✅ Placeholder text for empty state
- ✅ Focus management
Additional ARIA attributes (if needed):
tsx<textarea ref={textareaRef} aria-label="Markdown editor" aria-describedby="editor-description" // ... other props /> <p id="editor-description" className="sr-only"> Write your markdown content here. Use # for headings, ** for bold, etc. </p>
Performance Considerations
Large Documents
For documents with 1000+ lines:
- Debounce onChange:
tsximport { useDebouncedCallback } from 'use-debounce'; const debouncedChange = useDebouncedCallback( (content: string) => { setContent(content); }, 100 ); <Editor.Core content={content} onChange={debouncedChange} onSelectionChange={() => {}} textareaRef={textareaRef} />
- Throttle onSelectionChange:
tsximport { throttle } from 'lodash'; const throttledSelection = throttle( (selection: SelectionState) => { setSelection(selection); }, 100 );
Memory Optimization
For very long editing sessions, consider:
- Limiting history size (keep last 50 changes)
- Implementing pagination for history
- Using IndexedDB for persistent storage
Troubleshooting
Cursor jumping when typing
Problem: Cursor jumps to end of textarea
Solution: Ensure content prop and onChange are stable:
tsx// ❌ Bad: Creates new reference each render <Editor.Core content={content} onChange={(c) => setContent(c)} onSelectionChange={() => {}} textareaRef={textareaRef} /> // ✅ Good: Stable reference <Editor.Core content={content} onChange={setContent} onSelectionChange={setSelection} textareaRef={textareaRef} />
Selection not updating
Problem: onSelectionChange not firing
Solution: The callback fires on onSelect, onClick, and onKeyUp. Ensure you're not preventing these events:
tsx// Make sure no parent is stopping event propagation <div onClick={(e) => e.stopPropagation()}> {/* ❌ Bad */} <Editor.Core ... /> </div>
Ref is null
Problem: textareaRef.current is null
Solution: Ensure you're accessing ref after component mounts:
tsxuseEffect(() => { // ✅ Safe: Ref is guaranteed to be set if (textareaRef.current) { textareaRef.current.focus(); } }, []); // ❌ Unsafe: Ref might not be set yet const handleClick = () => { textareaRef.current?.focus(); // Might be null on first render };
Integration with useEditor Hook
Editor.Core works seamlessly with the useEditor hook:
tsximport { Editor, useEditor } from '@/components/ui/markdown-editor'; export default function IntegratedEditor() { const textareaRef = useRef<HTMLTextAreaElement>(null); const { content, selection, updateContent, setSelection } = useEditor('# Initial content'); return ( <Editor.Core content={content} onChange={updateContent} onSelectionChange={setSelection} textareaRef={textareaRef} /> ); }