Building a Custom Markdown Editor

This guide walks you through building a custom markdown editor from scratch using Nindo's core components. You'll create your own toolbar, layout, and integrate all the pieces together.

What We'll Build

A fully functional markdown editor with:

  • ✅ Custom toolbar with formatting actions
  • ✅ Split-view editor and preview
  • ✅ Undo/redo functionality
  • ✅ Keyboard shortcuts
  • ✅ View mode toggles
  • ✅ Status bar with statistics

Final Result:

tsx<CustomMarkdownEditor />

Prerequisites

Ensure you have Nindo installed:

bashnpx shadcn@latest add https://nindo.rasengan.dev/registry/markdown-editor.json

Step 1: Project Setup

create a reusable component:

bashmkdir -p src/components/custom-editor touch components/custom-editor/index.tsx

Step 2: Import Required Dependencies

tsx'use client'; import React, { useState, useRef, useCallback } from 'react'; import { Editor, useEditor, useEditorShortcuts, createToolbarActions, type ToolbarAction, type Block } from '@/components/ui/markdown-editor'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Bold, Italic, Code, Link2, Heading1, Heading2, Heading3, Quote, Braces, Undo2, Redo2, Eye, Edit2, Columns } from 'lucide-react';

Step 3: Create the Custom Toolbar Component

3.1 Define Toolbar Props

tsxinterface CustomToolbarProps { // Formatting actions actions: ToolbarAction[]; onAction: (action: ToolbarAction) => void; currentBlock: Block | null; // History onUndo: () => void; onRedo: () => void; canUndo: boolean; canRedo: boolean; // View mode viewMode: 'edit' | 'preview' | 'split'; onViewModeChange: (mode: 'edit' | 'preview' | 'split') => void; }

3.2 Implement Custom Toolbar

tsxconst CustomToolbar: React.FC<CustomToolbarProps> = ({ actions, onAction, currentBlock, onUndo, onRedo, canUndo, canRedo, viewMode, onViewModeChange }) => { // Helper to check if current block matches action const isActionActive = (actionId: string): boolean => { if (!currentBlock) return false; if (actionId.startsWith('h') && currentBlock.type === 'heading') { const level = parseInt(actionId.replace('h', '')); return currentBlock.level === level; } if (actionId === 'quote' && currentBlock.type === 'quote') return true; if (actionId === 'code-block' && currentBlock.type === 'code') return true; return false; }; return ( <div className="flex items-center justify-between border-b bg-background p-2 gap-2"> {/* Left: Formatting Actions */} <div className="flex items-center gap-1"> {/* Inline Formatting */} <div className="flex items-center gap-1"> {actions.slice(0, 4).map((action) => ( <Button key={action.id} size="sm" variant={isActionActive(action.id) ? 'default' : 'ghost'} onClick={() => onAction(action)} disabled={viewMode === 'preview'} title={`${action.label}${action.shortcut ? ` (${action.shortcut})` : ''}`} className="h-8 w-8 p-0" > {action.icon} </Button> ))} </div> <div className="w-px h-6 bg-border" /> {/* Block Formatting */} <div className="flex items-center gap-1"> {actions.slice(4).map((action) => ( <Button key={action.id} size="sm" variant={isActionActive(action.id) ? 'default' : 'ghost'} onClick={() => onAction(action)} disabled={viewMode === 'preview'} title={action.label} className="h-8 w-8 p-0" > {action.icon} </Button> ))} </div> </div> {/* Right: View Controls & History */} <div className="flex items-center gap-1"> {/* View Mode Toggles */} <div className="flex items-center gap-1"> <Button size="sm" variant={viewMode === 'edit' ? 'default' : 'ghost'} onClick={() => onViewModeChange('edit')} title="Edit Mode" className="h-8 w-8 p-0" > <Edit2 className="h-4 w-4" /> </Button> <Button size="sm" variant={viewMode === 'split' ? 'default' : 'ghost'} onClick={() => onViewModeChange('split')} title="Split Mode" className="h-8 w-8 p-0" > <Columns className="h-4 w-4" /> </Button> <Button size="sm" variant={viewMode === 'preview' ? 'default' : 'ghost'} onClick={() => onViewModeChange('preview')} title="Preview Mode" className="h-8 w-8 p-0" > <Eye className="h-4 w-4" /> </Button> </div> <div className="w-px h-6 bg-border" /> {/* History Controls */} <Button size="sm" variant="ghost" onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)" className="h-8 w-8 p-0" > <Undo2 className="h-4 w-4" /> </Button> <Button size="sm" variant="ghost" onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)" className="h-8 w-8 p-0" > <Redo2 className="h-4 w-4" /> </Button> </div> </div> ); };

Step 4: Create the Status Bar Component

tsxinterface StatusBarProps { currentBlock: Block | null; content: string; } const StatusBar: React.FC<StatusBarProps> = ({ currentBlock, content }) => { const lines = content.split('\n').length; const words = content.split(/\s+/).filter(Boolean).length; const characters = content.length; const blockType = currentBlock ? `${currentBlock.type}${currentBlock.level ? ` (H${currentBlock.level})` : ''}` : 'paragraph'; return ( <div className="flex items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-4"> <span className="font-medium capitalize">{blockType}</span> </div> <div className="flex items-center gap-4"> <span>{lines} lines</span> <span>{words} words</span> <span>{characters} characters</span> </div> </div> ); };

Step 5: Build the Main Editor Component

5.1 Set Up State and Refs

tsxconst CustomMarkdownEditor: React.FC = () => { // View mode state const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>('split'); // Ref for textarea const textareaRef = useRef<HTMLTextAreaElement>(null); // Toolbar actions const toolbarActions = createToolbarActions(); // Editor state from useEditor hook const { content, selection, blocks, currentBlock, updateContent, setSelection, undo, redo, canUndo, canRedo } = useEditor(`# Welcome to Custom Editor Start writing your **markdown** content here. ## Features - Custom toolbar - Live preview - Keyboard shortcuts - Undo/redo support \`\`\`javascript const greeting = "Hello, Nindo!"; console.log(greeting); \`\`\` > This is a custom implementation using Nindo's core components. Happy writing! ✨`); // Enable keyboard shortcuts useEditorShortcuts(textareaRef, updateContent); // Continue in next section... };

5.2 Implement Toolbar Action Handler

tsxconst CustomMarkdownEditor: React.FC = () => { // ... previous code ... // Handle toolbar formatting actions const handleToolbarAction = useCallback((action: ToolbarAction) => { const result = action.handler(content, selection); updateContent(result.content, result.selection); // Restore focus to editor after formatting setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.setSelectionRange( result.selection.start, result.selection.end ); } }, 0); }, [content, selection, updateContent]); // Continue in next section... };

5.3 Create the Layout

tsxconst CustomMarkdownEditor: React.FC = () => { // ... previous code ... return ( <div className="flex flex-col h-screen bg-background"> {/* Header */} <div className="border-b px-6 py-4"> <h1 className="text-2xl font-bold">Custom Markdown Editor</h1> <p className="text-sm text-muted-foreground"> Built with Nindo Core Components </p> </div> {/* Toolbar */} <CustomToolbar actions={toolbarActions} onAction={handleToolbarAction} currentBlock={currentBlock} onUndo={undo} onRedo={redo} canUndo={canUndo} canRedo={canRedo} viewMode={viewMode} onViewModeChange={setViewMode} /> {/* Editor Area */} <div className="flex-1 overflow-hidden"> <div className="h-full flex"> {/* Editor Panel */} {(viewMode === 'edit' || viewMode === 'split') && ( <Card className="flex-1 m-4 mr-2 shadow-none border overflow-hidden"> <Editor.Core content={content} onChange={updateContent} onSelectionChange={setSelection} textareaRef={textareaRef} placeholder="Start writing your markdown..." /> </Card> )} {/* Preview Panel */} {(viewMode === 'preview' || viewMode === 'split') && ( <Card className="flex-1 m-4 ml-2 shadow-none border overflow-hidden"> <Editor.Preview content={content} /> </Card> )} </div> </div> {/* Status Bar */} <StatusBar currentBlock={currentBlock} content={content} /> </div> ); }; export default CustomMarkdownEditor;

Step 6: Complete Implementation

Here's the full code in one file:

tsx'use client'; import React, { useState, useRef, useCallback } from 'react'; import { Editor, useEditor, useEditorShortcuts, createToolbarActions, type ToolbarAction, type Block } from '@/components/ui/markdown-editor'; import { Button } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; import { Undo2, Redo2, Eye, Edit2, Columns } from 'lucide-react'; // Toolbar Component interface CustomToolbarProps { actions: ToolbarAction[]; onAction: (action: ToolbarAction) => void; currentBlock: Block | null; onUndo: () => void; onRedo: () => void; canUndo: boolean; canRedo: boolean; viewMode: 'edit' | 'preview' | 'split'; onViewModeChange: (mode: 'edit' | 'preview' | 'split') => void; } const CustomToolbar: React.FC<CustomToolbarProps> = ({ actions, onAction, currentBlock, onUndo, onRedo, canUndo, canRedo, viewMode, onViewModeChange }) => { const isActionActive = (actionId: string): boolean => { if (!currentBlock) return false; if (actionId.startsWith('h') && currentBlock.type === 'heading') { const level = parseInt(actionId.replace('h', '')); return currentBlock.level === level; } if (actionId === 'quote' && currentBlock.type === 'quote') return true; if (actionId === 'code-block' && currentBlock.type === 'code') return true; return false; }; return ( <div className="flex items-center justify-between border-b bg-background p-2 gap-2"> <div className="flex items-center gap-1"> <div className="flex items-center gap-1"> {actions.slice(0, 4).map((action) => ( <Button key={action.id} size="sm" variant={isActionActive(action.id) ? 'default' : 'ghost'} onClick={() => onAction(action)} disabled={viewMode === 'preview'} title={`${action.label}${action.shortcut ? ` (${action.shortcut})` : ''}`} className="h-8 w-8 p-0" > {action.icon} </Button> ))} </div> <div className="w-px h-6 bg-border" /> <div className="flex items-center gap-1"> {actions.slice(4).map((action) => ( <Button key={action.id} size="sm" variant={isActionActive(action.id) ? 'default' : 'ghost'} onClick={() => onAction(action)} disabled={viewMode === 'preview'} title={action.label} className="h-8 w-8 p-0" > {action.icon} </Button> ))} </div> </div> <div className="flex items-center gap-1"> <div className="flex items-center gap-1"> <Button size="sm" variant={viewMode === 'edit' ? 'default' : 'ghost'} onClick={() => onViewModeChange('edit')} title="Edit Mode" className="h-8 w-8 p-0" > <Edit2 className="h-4 w-4" /> </Button> <Button size="sm" variant={viewMode === 'split' ? 'default' : 'ghost'} onClick={() => onViewModeChange('split')} title="Split Mode" className="h-8 w-8 p-0" > <Columns className="h-4 w-4" /> </Button> <Button size="sm" variant={viewMode === 'preview' ? 'default' : 'ghost'} onClick={() => onViewModeChange('preview')} title="Preview Mode" className="h-8 w-8 p-0" > <Eye className="h-4 w-4" /> </Button> </div> <div className="w-px h-6 bg-border" /> <Button size="sm" variant="ghost" onClick={onUndo} disabled={!canUndo} title="Undo (Ctrl+Z)" className="h-8 w-8 p-0" > <Undo2 className="h-4 w-4" /> </Button> <Button size="sm" variant="ghost" onClick={onRedo} disabled={!canRedo} title="Redo (Ctrl+Shift+Z)" className="h-8 w-8 p-0" > <Redo2 className="h-4 w-4" /> </Button> </div> </div> ); }; // Status Bar Component interface StatusBarProps { currentBlock: Block | null; content: string; } const StatusBar: React.FC<StatusBarProps> = ({ currentBlock, content }) => { const lines = content.split('\n').length; const words = content.split(/\s+/).filter(Boolean).length; const characters = content.length; const blockType = currentBlock ? `${currentBlock.type}${currentBlock.level ? ` (H${currentBlock.level})` : ''}` : 'paragraph'; return ( <div className="flex items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-4"> <span className="font-medium capitalize">{blockType}</span> </div> <div className="flex items-center gap-4"> <span>{lines} lines</span> <span>{words} words</span> <span>{characters} characters</span> </div> </div> ); }; // Main Editor Component const CustomMarkdownEditor: React.FC = () => { const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'split'>('split'); const textareaRef = useRef<HTMLTextAreaElement>(null); const toolbarActions = createToolbarActions(); const { content, selection, blocks, currentBlock, updateContent, setSelection, undo, redo, canUndo, canRedo } = useEditor(`# Welcome to Custom Editor Start writing your **markdown** content here. ## Features - Custom toolbar - Live preview - Keyboard shortcuts - Undo/redo support \`\`\`javascript const greeting = "Hello, Nindo!"; console.log(greeting); \`\`\` > This is a custom implementation using Nindo's core components. Happy writing! ✨`); useEditorShortcuts(textareaRef, updateContent); const handleToolbarAction = useCallback((action: ToolbarAction) => { const result = action.handler(content, selection); updateContent(result.content, result.selection); setTimeout(() => { if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.setSelectionRange( result.selection.start, result.selection.end ); } }, 0); }, [content, selection, updateContent]); return ( <div className="flex flex-col h-screen bg-background"> <div className="border-b px-6 py-4"> <h1 className="text-2xl font-bold">Custom Markdown Editor</h1> <p className="text-sm text-muted-foreground"> Built with Nindo Core Components </p> </div> <CustomToolbar actions={toolbarActions} onAction={handleToolbarAction} currentBlock={currentBlock} onUndo={undo} onRedo={redo} canUndo={canUndo} canRedo={canRedo} viewMode={viewMode} onViewModeChange={setViewMode} /> <div className="flex-1 overflow-hidden"> <div className="h-full flex"> {(viewMode === 'edit' || viewMode === 'split') && ( <Card className="flex-1 m-4 mr-2 shadow-none border overflow-hidden"> <Editor.Core content={content} onChange={updateContent} onSelectionChange={setSelection} textareaRef={textareaRef} placeholder="Start writing your markdown..." /> </Card> )} {(viewMode === 'preview' || viewMode === 'split') && ( <Card className="flex-1 m-4 ml-2 shadow-none border overflow-hidden"> <Editor.Preview content={content} /> </Card> )} </div> </div> <StatusBar currentBlock={currentBlock} content={content} /> </div> ); }; export default CustomMarkdownEditor;

Step 7: Usage

In a Page (Rasengan.js File-based routing)

tsx// src/app/_routes/editor/index.page.tsx import CustomMarkdownEditor from '@/components/custom-editor'; export default function EditorPage() { return <CustomMarkdownEditor />; }

As a Component

tsx// Use anywhere in your app import CustomMarkdownEditor from '@/components/custom-editor'; function MyApp() { return ( <div> <h1>My App</h1> <CustomMarkdownEditor /> </div> ); }

Here is the preview


Preview

Custom Markdown Editor

Built with Nindo Core Components

Welcome to Custom Editor

Start writing your markdown content here.

Features

  • Custom toolbar
  • Live preview
  • Keyboard shortcuts
  • Undo/redo support
javascript
1const greeting = "Hello, Nindo!"; 2console.log(greeting);

This is a custom implementation using Nindo's core components.

Happy writing! ✨

heading (H1)
19 lines46 words326 characters

Step 8: Enhancements

8.1 Add Auto-Save

tsxconst CustomMarkdownEditor: React.FC = () => { // ... existing code ... 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]); // Update header to show save status return ( <div className="flex flex-col h-screen bg-background"> <div className="border-b px-6 py-4 flex justify-between items-center"> <div> <h1 className="text-2xl font-bold">Custom Markdown Editor</h1> <p className="text-sm text-muted-foreground"> Built with Nindo Core Components </p> </div> <div className="text-sm text-muted-foreground"> {isSaving ? 'Saving...' : lastSaved ? `Saved at ${lastSaved.toLocaleTimeString()}` : ''} </div> </div> {/* ... rest of component */} </div> ); };

8.2 Add Dark Mode Toggle

tsximport { Moon, Sun } from 'lucide-react'; import { useTheme } from '@rasenganjs/theme'; const CustomToolbar: React.FC<CustomToolbarProps> = (props) => { const { isDark, setTheme } = useTheme(); return ( <div className="flex items-center justify-between border-b bg-background p-2 gap-2"> {/* ... existing toolbar content ... */} <div className="flex items-center gap-1"> {/* ... existing right side ... */} <Separator orientation="vertical" className="h-6" /> <Button size="sm" variant="ghost" onClick={() => setTheme(isDark ? 'light' : 'dark')} title="Toggle Theme" className="h-8 w-8 p-0" > {isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} </Button> </div> </div> ); };

8.3 Add Export Functionality

tsximport { Download } from 'lucide-react'; const CustomMarkdownEditor: React.FC = () => { // ... existing code ... const exportMarkdown = () => { const blob = new Blob([content], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'document.md'; a.click(); URL.revokeObjectURL(url); }; return ( <div className="flex flex-col h-screen bg-background"> <div className="border-b px-6 py-4 flex justify-between items-center"> <div> <h1 className="text-2xl font-bold">Custom Markdown Editor</h1> </div> <div className="flex gap-2"> <Button size="sm" variant="outline" onClick={exportMarkdown}> <Download className="h-4 w-4 mr-2" /> Export MD </Button> </div> </div> {/* ... rest of component */} </div> ); };

Step 9: Advanced Customizations

9.1 Custom Toolbar Actions

tsximport { Strikethrough, Table } from 'lucide-react'; const CustomMarkdownEditor: React.FC = () => { // Create custom actions const customActions: ToolbarAction[] = [ ...createToolbarActions(), { id: 'strikethrough', label: 'Strikethrough', icon: <Strikethrough className="h-4 w-4" />, handler: (content, selection) => { const before = content.slice(0, selection.start); const selected = content.slice(selection.start, selection.end); const after = content.slice(selection.end); return { content: `${before}~~${selected}~~${after}`, selection: { start: selection.start + 2, end: selection.end + 2 } }; } }, { id: 'table', label: 'Insert Table', icon: <Table className="h-4 w-4" />, handler: (content, selection) => { const table = '\n| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |\n'; return { content: content.slice(0, selection.start) + table + content.slice(selection.end), selection: { start: selection.start + 1, end: selection.start + 1 } }; } } ]; // Use customActions instead of toolbarActions // ... };

9.2 Add Search Functionality

tsximport { Search } from 'lucide-react'; import { Input } from '@/components/ui/input'; const CustomMarkdownEditor: React.FC = () => { const [searchTerm, setSearchTerm] = useState(''); const [showSearch, setShowSearch] = useState(false); const highlightSearch = (text: string) => { if (!searchTerm) return text; const regex = new RegExp(`(${searchTerm})`, 'gi'); return text.replace(regex, '<mark>$1</mark>'); }; return ( <div className="flex flex-col h-screen bg-background"> {/* ... header ... */} <CustomToolbar // ... existing props ... > <Button size="sm" variant="ghost" onClick={() => setShowSearch(!showSearch)} className="h-8 w-8 p-0" > <Search className="h-4 w-4" /> </Button> </CustomToolbar> {showSearch && ( <div className="border-b p-2"> <Input type="text" placeholder="Search..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="max-w-sm" /> </div> )} {/* ... rest of component ... */} </div> ); };

Summary

You've now built a complete custom markdown editor with:

Custom Toolbar - Formatting, view modes, undo/redo ✅ Editor Component - Using Editor.Core ✅ Preview Component - Using Editor.Preview ✅ Status Bar - Real-time statistics ✅ Keyboard Shortcuts - Auto-pairing, Tab, B/I ✅ History Management - Full undo/redo ✅ View Modes - Edit, preview, and split views

Key Components Used:

  • Editor.Core - Textarea editor
  • Editor.Preview - Markdown preview
  • useEditor - State management
  • useEditorShortcuts - Keyboard shortcuts
  • createToolbarActions - Default actions

What You Can Do Next:

  • Add more custom toolbar actions
  • Implement auto-save
  • Add export functionality
  • Create custom themes
  • Add collaborative editing
  • Implement file upload
  • Add syntax highlighting options

Your custom editor is now ready for production use! 🎉