Split Markdown Editor
Machine-coding brief for split markdown edit/preview — sync scroll optional, sanitize HTML, debounce render.
intermediate4 min read
- machine-coding
- interview
- react
- markdown
Problem statement
Build a split markdown editor: left pane raw markdown textarea, right pane live preview. Interviewers score controlled state, safe HTML rendering, and performance (don’t re-parse on every keystroke without debounce). Full CommonMark compliance is not required — a minimal subset or a library is fine if allowed.
Requirements
Must have
- Textarea bound to markdown string
- Preview updates from markdown
- Support at least: headings, bold/italic, links, lists, code (via library or tiny parser)
- Layout: 50/50 split on desktop; stack on small screens
- Accessible labels for editor and preview
Should have
- Debounced preview (100–200ms)
- Sanitize HTML if using
dangerouslySetInnerHTML - Word/char count
- Toolbar insert helpers (
**bold**)
Nice to have
- Sync scroll between panes
- LocalStorage draft
- Diff view
Planning (5 minutes out loud)
- Library policy — ask if
marked+DOMPurifyallowed; else subset regex parser for demo - Single source of truth — markdown string
- Security — never unsanitized HTML
- MVP — textarea + preview with bold/heading; then debounce + sanitize
- contentEditable is the wrong tree for this problem
Architecture
SplitMarkdownEditor
├── EditorPane (textarea)
├── PreviewPane (article)
└── useDebouncedValue + renderMarkdown
API
type SplitMarkdownEditorProps = {
value: string;
onChange: (value: string) => void;
debounceMs?: number;
};
Implementation sketch
Debounce
function useDebouncedValue<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const id = window.setTimeout(() => setV(value), ms);
return () => window.clearTimeout(id);
}, [value, ms]);
return v;
}
Tiny safe subset (if no libs)
function escapeHtml(s: string) {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
/** Demo-only subset — not CommonMark */
function renderMarkdownLite(md: string): string {
const escaped = escapeHtml(md);
return escaped
.replace(/^### (.*)$/gm, "<h3>$1</h3>")
.replace(/^## (.*)$/gm, "<h2>$1</h2>")
.replace(/^# (.*)$/gm, "<h1>$1</h1>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
'<a href="$2" rel="noopener noreferrer">$1</a>'
)
.replace(/\n/g, "<br />");
}
With libraries:
import { marked } from "marked";
import DOMPurify from "dompurify";
function renderMarkdown(md: string) {
const html = marked.parse(md, { async: false }) as string;
return DOMPurify.sanitize(html);
}
Component
function SplitMarkdownEditor({
value,
onChange,
debounceMs = 150,
}: SplitMarkdownEditorProps) {
const debounced = useDebouncedValue(value, debounceMs);
const html = useMemo(() => renderMarkdownLite(debounced), [debounced]);
return (
<div className="split-md">
<label className="pane">
<span>Markdown</span>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
</label>
<div className="pane">
<span id="preview-label">Preview</span>
<article
className="preview"
aria-labelledby="preview-label"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
</div>
);
}
Toolbar insert
function wrapSelection(
value: string,
start: number,
end: number,
before: string,
after: string
) {
return {
next:
value.slice(0, start) +
before +
value.slice(start, end) +
after +
value.slice(end),
caret: end + before.length + after.length,
};
}
Accessibility essentials
- Labeled textarea
- Preview is an
articlewith heading; not a live region on every keystroke (too noisy) — optional polite update on debounce settle is debatable - Links in preview open safely (
relon user links) - Keyboard usable toolbar buttons
Performance notes
- Debounce parse; memo HTML
- Large docs: Web Worker for parse
- Avoid syncing scroll every pixel without rAF
Sync scroll (optional)
function syncScroll(source: HTMLElement, target: HTMLElement) {
const ratio =
source.scrollTop / (source.scrollHeight - source.clientHeight || 1);
target.scrollTop = ratio * (target.scrollHeight - target.clientHeight);
}
Footguns
- XSS via raw HTML in markdown (
<script>) — escape/sanitize - javascript: URLs in links
- Re-parsing on every key without debounce → jank
- Controlled textarea caret jumps if you reformat value while typing
- Claiming full markdown while shipping regex toys — be honest
Interview out-loud answer
Split editor keeps one markdown string. The textarea is controlled; preview renders a debounced, sanitized HTML transformation. I’d use marked+DOMPurify if allowed, otherwise a tiny escaped subset. Security of links and raw HTML is the first footgun I mention. Sync scroll and drafts are polish.
Related on this site
- Rich Text Toolbar
- Code Editor Line Numbers
- Security Interview Talking Points
- Machine Coding Interview Framework