useEditor Hook

The useEditor hook is the core state management solution for Nindo. It handles content, selection tracking, block parsing, and undo/redo history with a simple, intuitive API.

Import

tsximport { useEditor } from '@/components/ui/markdown-editor';

Overview

useEditor encapsulates all the state and logic needed to build a markdown editor:

  • Content management - Stores and updates markdown text
  • Selection tracking - Tracks cursor position and text selection
  • Block parsing - Automatically parses content into blocks
  • History management - Built-in undo/redo with keyboard shortcuts
  • Current block detection - Identifies the block at cursor position

Signature

tsxconst useEditor = (initialContent?: string) => { // Returns editor state and methods }

Parameters

initialContent

  • Type: string
  • Optional: Yes
  • Default: '' (empty string)
  • Description: Initial markdown content to populate the editor
tsx// Empty editor const editor = useEditor(); // Pre-populated editor const editor = useEditor('# Hello World\n\nWelcome to Nindo!'); // Load from variable const savedContent = '## My Draft'; const editor = useEditor(savedContent);

Return Value

The hook returns an object with the following properties:

tsxinterface EditorState { // State content: string; selection: SelectionState; blocks: Block[]; currentBlock: Block | null; canUndo: boolean; canRedo: boolean; // Methods updateContent: (newContent: string, newSelection?: SelectionState) => void; setSelection: (selection: SelectionState) => void; undo: () => void; redo: () => void; }

Properties

content

  • Type: string
  • Description: Current markdown content in the editor
tsxconst { content } = useEditor('# Title'); console.log(content); // "# Title"

Use cases:

  • Display content in preview
  • Save to backend
  • Calculate statistics (word count, etc.)
  • Export to file

selection

  • Type: SelectionState
  • Description: Current cursor position or selected text range
tsxinterface SelectionState { start: number; // Start position (character index) end: number; // End position (character index) } const { selection } = useEditor(); // Cursor at position 10 (no selection) // selection = { start: 10, end: 10 } // Selected text from position 5 to 15 // selection = { start: 5, end: 15 }

Use cases:

  • Apply formatting to selected text
  • Insert text at cursor position
  • Highlight current line
  • Context-aware toolbar

blocks

  • Type: Block[]
  • Description: Parsed array of blocks from the content (memoized)
tsxinterface Block { id: string; type: BlockType; // 'paragraph' | 'heading' | 'code' | 'quote' | 'list' | 'image' level?: number; // For headings (1-6) startOffset: number; endOffset: number; content: string; } const { blocks } = useEditor('# Title\n\nParagraph'); console.log(blocks); // [ // { id: 'block-0', type: 'heading', level: 1, content: '# Title', ... }, // { id: 'block-1', type: 'paragraph', content: '', ... }, // { id: 'block-2', type: 'paragraph', content: 'Paragraph', ... } // ]

Use cases:

  • Display table of contents
  • Block-level operations (delete block, move block)
  • Navigation between blocks
  • Structure analysis

currentBlock

  • Type: Block | null
  • Description: The block at the current cursor position (memoized)
tsxconst { currentBlock, selection } = useEditor('# Title\n\nText'); // When cursor is in the heading // currentBlock = { type: 'heading', level: 1, content: '# Title', ... } // When cursor is in the paragraph // currentBlock = { type: 'paragraph', content: 'Text', ... }

Use cases:

  • Context-aware toolbar (highlight active formatting)
  • Display current block type in status bar
  • Block-specific shortcuts
  • Smart formatting suggestions

canUndo

  • Type: boolean
  • Description: Whether undo action is available
tsxconst { canUndo, undo } = useEditor('Initial'); console.log(canUndo); // false (at start of history) // Make a change updateContent('Modified'); console.log(canUndo); // true (can undo to previous state)

Use cases:

  • Disable/enable undo button
  • Show undo availability in UI
  • Prevent unnecessary undo attempts

canRedo

  • Type: boolean
  • Description: Whether redo action is available
tsxconst { canRedo, redo, undo } = useEditor('Initial'); console.log(canRedo); // false (nothing to redo) // Make changes and undo updateContent('Modified'); undo(); console.log(canRedo); // true (can redo)

Use cases:

  • Disable/enable redo button
  • Show redo availability in UI
  • Prevent unnecessary redo attempts

Methods

updateContent

Updates the editor content and optionally the selection.

Signature:

tsxupdateContent: (newContent: string, newSelection?: SelectionState) => void

Parameters:

  • newContent (required) - The new markdown content
  • newSelection (optional) - New cursor/selection position

Example:

tsxconst { content, updateContent } = useEditor('Hello'); // Simple content update updateContent('Hello World'); // Update content and move cursor updateContent('Hello World', { start: 11, end: 11 }); // Replace selected text const newText = content.slice(0, 5) + 'Nindo' + content.slice(10); updateContent(newText, { start: 5, end: 10 });

Behavior:

  • Adds change to history (enables undo)
  • Clears any forward history (can't redo after new change)
  • Triggers block re-parsing
  • Updates current block

setSelection

Updates the cursor position or text selection without modifying content.

Signature:

tsxsetSelection: (selection: SelectionState) => void

Example:

tsxconst { setSelection } = useEditor('Hello World'); // Move cursor to position 5 setSelection({ start: 5, end: 5 }); // Select text from position 0 to 5 setSelection({ start: 0, end: 5 }); // Select all const length = content.length; setSelection({ start: 0, end: length });

Use cases:

  • Programmatic cursor movement
  • Text selection after formatting
  • Focus restoration
  • Navigation commands

undo

Reverts to the previous state in history.

Signature:

tsxundo: () => void

Example:

tsxconst { content, updateContent, undo, canUndo } = useEditor('Initial'); updateContent('Modified'); console.log(content); // "Modified" if (canUndo) { undo(); console.log(content); // "Initial" }

Behavior:

  • Moves backward in history
  • Restores previous content
  • Does nothing if at start of history
  • Keyboard shortcut: Ctrl/Cmd + Z (automatic)

redo

Reapplies the next change in history.

Signature:

tsxredo: () => void

Example:

tsxconst { content, updateContent, undo, redo, canRedo } = useEditor('Initial'); updateContent('Modified'); undo(); console.log(content); // "Initial" if (canRedo) { redo(); console.log(content); // "Modified" }

Behavior:

  • Moves forward in history
  • Restores next content state
  • Does nothing if at end of history
  • Keyboard shortcut: Ctrl/Cmd + Shift + Z (automatic)

Basic Usage

Simple Editor

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; import { useRef } from 'react'; export default function SimpleEditor() { const textareaRef = useRef<HTMLTextAreaElement>(null); const { content, updateContent, setSelection } = useEditor('# Hello'); return ( <textarea ref={textareaRef} value={content} onChange={(e) => updateContent(e.target.value)} onSelect={() => { if (textareaRef.current) { setSelection({ start: textareaRef.current.selectionStart, end: textareaRef.current.selectionEnd }); } }} className="w-full h-screen p-4 font-mono" /> ); }

With Preview

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; import { Editor } from '@/components/ui/markdown-editor'; import { useRef } from 'react'; export default function EditorWithPreview() { const textareaRef = useRef<HTMLTextAreaElement>(null); const { content, selection, updateContent, setSelection } = useEditor('# Title'); return ( <div className="grid grid-cols-2 gap-4 h-screen p-4"> <Editor.Core content={content} onChange={updateContent} onSelectionChange={setSelection} textareaRef={textareaRef} /> <Editor.Preview content={content} /> </div> ); }

With Toolbar

tsx'use client'; import { useEditor, createToolbarActions, Editor } from '@/components/ui/markdown-editor'; import { useRef } from 'react'; export default function FullEditor() { const textareaRef = useRef<HTMLTextAreaElement>(null); const toolbarActions = createToolbarActions(); const { content, selection, currentBlock, updateContent, setSelection, undo, redo, canUndo, canRedo } = useEditor('# Start Writing'); const handleAction = (action: any) => { const result = action.handler(content, selection); updateContent(result.content, result.selection); // Restore focus setTimeout(() => { textareaRef.current?.focus(); textareaRef.current?.setSelectionRange( result.selection.start, result.selection.end ); }, 0); }; return ( <div className="h-screen flex flex-col"> <Editor.Toolbar actions={toolbarActions} onAction={handleAction} currentBlock={currentBlock} undo={undo} redo={redo} canUndo={canUndo} canRedo={canRedo} /> <Editor.Core content={content} onChange={updateContent} onSelectionChange={setSelection} textareaRef={textareaRef} /> </div> ); }

Advanced Examples

Auto-Save with useEditor

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; import { useEffect, useState } from 'react'; export default function AutoSaveEditor() { const { content, updateContent } = useEditor(''); const [lastSaved, setLastSaved] = useState<Date | null>(null); const [isSaving, setIsSaving] = useState(false); useEffect(() => { const timer = setTimeout(async () => { if (!content) return; setIsSaving(true); try { await fetch('/api/save', { method: 'POST', body: JSON.stringify({ content }) }); setLastSaved(new Date()); } finally { setIsSaving(false); } }, 2000); return () => clearTimeout(timer); }, [content]); return ( <div> <div className="text-sm text-muted-foreground mb-2"> {isSaving ? 'Saving...' : lastSaved ? `Saved at ${lastSaved.toLocaleTimeString()}` : 'Not saved'} </div> {/* Editor UI */} </div> ); }

Character Limit

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; export default function LimitedEditor() { const MAX_LENGTH = 5000; const { content, updateContent } = useEditor(''); const handleUpdate = (newContent: string) => { if (newContent.length <= MAX_LENGTH) { updateContent(newContent); } }; const remaining = MAX_LENGTH - content.length; return ( <div> <div className={`text-sm mb-2 ${remaining < 100 ? 'text-red-500' : 'text-muted-foreground'}`}> {remaining} / {MAX_LENGTH} characters remaining </div> {/* Editor UI with handleUpdate instead of updateContent */} </div> ); }

Table of Contents

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; export default function EditorWithTOC() { const { content, blocks, setSelection } = useEditor('# Title\n\n## Section 1\n\n## Section 2'); const headings = blocks.filter(b => b.type === 'heading'); const jumpToHeading = (block: Block) => { setSelection({ start: block.startOffset, end: block.startOffset }); // Scroll to position }; return ( <div className="grid grid-cols-[250px_1fr] gap-4"> {/* Table of Contents */} <div className="border-r pr-4"> <h3 className="font-bold mb-2">Contents</h3> <ul className="space-y-1"> {headings.map(block => ( <li key={block.id} className="cursor-pointer hover:text-primary" style={{ paddingLeft: `${(block.level || 1) * 8}px` }} onClick={() => jumpToHeading(block)} > {block.content.replace(/^#+\s/, '')} </li> ))} </ul> </div> {/* Editor */} <div>{/* Editor UI */}</div> </div> ); }

Word Count Statistics

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; export default function EditorWithStats() { const { content, blocks } = useEditor(''); const stats = { characters: content.length, words: content.split(/\s+/).filter(Boolean).length, lines: content.split('\n').length, paragraphs: blocks.filter(b => b.type === 'paragraph' && b.content.trim()).length, headings: blocks.filter(b => b.type === 'heading').length, codeBlocks: blocks.filter(b => b.type === 'code').length, }; return ( <div> {/* Editor UI */} <div className="border-t p-4 text-sm text-muted-foreground"> <div className="grid grid-cols-6 gap-4"> <div>{stats.characters} chars</div> <div>{stats.words} words</div> <div>{stats.lines} lines</div> <div>{stats.paragraphs} paragraphs</div> <div>{stats.headings} headings</div> <div>{stats.codeBlocks} code blocks</div> </div> </div> </div> ); }

Search and Replace

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; export default function SearchReplaceEditor() { const { content, updateContent } = useEditor(''); const [searchTerm, setSearchTerm] = useState(''); const [replaceTerm, setReplaceTerm] = useState(''); const handleReplace = () => { const newContent = content.replace(new RegExp(searchTerm, 'g'), replaceTerm); updateContent(newContent); }; const matchCount = (content.match(new RegExp(searchTerm, 'g')) || []).length; return ( <div className="h-screen flex flex-col"> <div className="border-b p-4 flex gap-2 items-center"> <Input placeholder="Find..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-48" /> <Input placeholder="Replace with..." value={replaceTerm} onChange={(e) => setReplaceTerm(e.target.value)} className="w-48" /> <Button onClick={handleReplace} disabled={!searchTerm}> Replace All ({matchCount}) </Button> </div> {/* Editor UI */} </div> ); }

Custom History Limit

tsx'use client'; import { useEditor } from '@/components/ui/markdown-editor'; import { useEffect } from 'react'; export default function LimitedHistoryEditor() { const editor = useEditor(''); const MAX_HISTORY = 50; // Extend useEditor with custom history management useEffect(() => { // This is just a conceptual example // You'd need to modify the hook itself for production use if (editor.history?.length > MAX_HISTORY) { // Trim old history } }, [editor.content]); return <div>{/* Editor UI */}</div>; }

State Management Details

History Stack

The hook maintains a history stack internally:

tsx// Initial state history = ['Initial content'] historyIndex = 0 // After first change updateContent('Modified'); history = ['Initial content', 'Modified'] historyIndex = 1 // After undo undo(); history = ['Initial content', 'Modified'] // Stack preserved historyIndex = 0 // After new change (clears forward history) updateContent('New content'); history = ['Initial content', 'New content'] // 'Modified' removed historyIndex = 1

Block Parsing

Blocks are automatically parsed using useMemo:

tsxconst blocks = useMemo(() => parseBlocks(content), [content]);

Performance:

  • Only re-parses when content changes
  • Cached between renders
  • Efficient for large documents (< 10ms for 1000 lines)

Current Block Detection

Current block is detected using useMemo:

tsxconst currentBlock = useMemo( () => getCurrentBlock(blocks, selection.start), [blocks, selection] );

Performance:

  • Only recalculates when blocks or selection changes
  • O(n) complexity where n = number of blocks
  • Instant for typical documents (< 100 blocks)

Keyboard Shortcuts

The hook automatically registers keyboard shortcuts:

ShortcutActionPlatform
Ctrl + ZUndoWindows/Linux
Cmd + ZUndomacOS
Ctrl + Shift + ZRedoWindows/Linux
Cmd + Shift + ZRedomacOS

Note: These are global shortcuts that work even when editor is not focused. Use with caution in complex UIs.

Performance Considerations

Memory Usage

  • History grows with each change
  • Consider implementing history limits for long editing sessions
  • Typical usage: ~50-100 history entries = ~50KB memory

Optimization Tips

  1. Debounce updates for auto-save:
tsximport { useDebouncedCallback } from 'use-debounce'; const debouncedSave = useDebouncedCallback( (content: string) => { // Save to backend }, 1000 ); useEffect(() => { debouncedSave(content); }, [content]);
  1. Limit history size:
tsx// In updateContent setHistory(prev => { const newHistory = [...prev.slice(0, historyIndex + 1), newContent]; return newHistory.slice(-50); // Keep last 50 entries });
  1. Virtualize long block lists:
tsximport { Virtuoso } from 'react-virtuoso'; <Virtuoso data={blocks} itemContent={(index, block) => <BlockComponent block={block} />} />

TypeScript Support

The hook is fully typed:

tsxinterface SelectionState { start: number; end: number; } interface Block { id: string; type: BlockType; level?: number; startOffset: number; endOffset: number; content: string; } type BlockType = 'paragraph' | 'heading' | 'code' | 'quote' | 'list' | 'image'; function useEditor(initialContent?: string): { content: string; selection: SelectionState; blocks: Block[]; currentBlock: Block | null; updateContent: (newContent: string, newSelection?: SelectionState) => void; setSelection: (selection: SelectionState) => void; undo: () => void; redo: () => void; canUndo: boolean; canRedo: boolean; }

Troubleshooting

History not working

Problem: Undo/redo doesn't work

Solution: Ensure you're using updateContent instead of directly setting content:

tsx// ❌ Bad: Bypasses history setContent(newContent); // ✅ Good: Adds to history updateContent(newContent);

Cursor jumping

Problem: Cursor jumps to end after typing

Solution: Pass selection to updateContent:

tsx// ❌ Bad: Selection not updated updateContent(newContent); // ✅ Good: Selection maintained updateContent(newContent, { start: cursorPos, end: cursorPos });

Blocks not updating

Problem: Blocks array doesn't reflect content changes

Solution: Blocks are memoized and should update automatically. If not, check:

  • Content is being updated via updateContent
  • No stale closures in event handlers
  • React version >= 18

Integration with Other Hooks

With useMarkdownShortcuts

tsxconst editor = useEditor(''); useMarkdownShortcuts(textareaRef, editor.updateContent); // Shortcuts automatically update content with history

With Custom Hooks

tsxfunction useAutoSave(content: string) { useEffect(() => { const timer = setTimeout(() => save(content), 2000); return () => clearTimeout(timer); }, [content]); } const editor = useEditor(''); useAutoSave(editor.content);