Markdown Editor Implementation
Markdown editor allows users to create formatted content without WYSIWYG complexity. Popular options: CodeMirror + marked.js, TipTap with Markdown extensions, @uiw/react-md-editor.
@uiw/react-md-editor: Quick Start
import MDEditor from '@uiw/react-md-editor';
import { useState } from 'react';
function MarkdownEditor({ initialValue = '', onChange }: EditorProps) {
const [value, setValue] = useState(initialValue);
const handleChange = (val?: string) => {
const markdown = val ?? '';
setValue(markdown);
onChange?.(markdown);
};
return (
<MDEditor
value={value}
onChange={handleChange}
height={400}
preview="live"
hideToolbar={false}
commands={[
MDEditor.commands.bold,
MDEditor.commands.italic,
MDEditor.commands.title,
MDEditor.commands.divider,
MDEditor.commands.link,
MDEditor.commands.image,
MDEditor.commands.code,
MDEditor.commands.codeBlock,
MDEditor.commands.divider,
MDEditor.commands.fullscreen,
]}
/>
);
}
CodeMirror 6 + marked.js: Custom Implementation
import { EditorView, basicSetup } from 'codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
function createMarkdownEditor(container: HTMLElement, previewContainer: HTMLElement) {
const view = new EditorView({
doc: '',
extensions: [
basicSetup,
markdown(),
oneDark,
EditorView.updateListener.of(update => {
if (update.docChanged) {
const markdown = update.state.doc.toString();
const html = marked(markdown, { breaks: true, gfm: true });
previewContainer.innerHTML = DOMPurify.sanitize(html as string);
}
}),
],
parent: container,
});
return view;
}
Image Upload from Editor
// Custom command for image insert with upload
import * as commands from '@uiw/react-md-editor/commands';
const imageUploadCommand: commands.ICommand = {
name: 'upload-image',
keyCommand: 'upload-image',
buttonProps: { 'aria-label': 'Upload image' },
icon: <ImageIcon />,
execute: async (state, api) => {
const file = await openFilePicker(['image/jpeg', 'image/png', 'image/webp']);
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const { data } = await api.post('/api/media/upload', formData);
const imageMarkdown = ``;
api.replaceSelection(imageMarkdown);
},
};
async function openFilePicker(accept: string[]): Promise<File | null> {
return new Promise(resolve => {
const input = document.createElement('input');
input.type = 'file';
input.accept = accept.join(',');
input.onchange = () => resolve(input.files?.[0] ?? null);
input.click();
});
}
Server-side Processing: Sanitization
use League\CommonMark\CommonMarkConverter;
use League\HTMLToMarkdown\HtmlConverter;
class MarkdownService
{
private CommonMarkConverter $converter;
public function __construct()
{
$this->converter = new CommonMarkConverter([
'html_input' => 'strip', // remove raw HTML
'allow_unsafe_links' => false,
'max_nesting_level' => 5,
]);
}
public function toHtml(string $markdown): string
{
return $this->converter->convert($markdown)->getContent();
}
public function sanitize(string $markdown): string
{
// Remove potentially dangerous constructs
$markdown = preg_replace('/\[([^\]]+)\]\(javascript:[^)]+\)/', '[$1]', $markdown);
return strip_tags($markdown); // clean if HTML got through
}
}
Storage: Markdown vs HTML
Store original Markdown (for editing) and render to HTML on display. Cache HTML in body_html field or Redis.
// Article model
class Article extends Model
{
protected static function booted(): void
{
static::saving(function (Article $article) {
if ($article->isDirty('body_markdown')) {
$article->body_html = app(MarkdownService::class)
->toHtml($article->body_markdown);
}
});
}
}
Implementation Timeline
@uiw/react-md-editor with image upload and server sanitization: 2–3 days. Custom CodeMirror editor with live-preview and syntax extensions: 3–5 days.







