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

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

5000 / 5000

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:

  1. Debounce onChange:
tsximport { useDebouncedCallback } from "use-debounce"; const debouncedChange = useDebouncedCallback((content: string) => { setContent(content); }, 100); <Editor.Core content={content} onChange={debouncedChange} onSelectionChange={() => {}} textareaRef={textareaRef} />;
  1. 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} /> ); }