md-editor – Lightweight Markdown Editor with Live Preview

How and why a lightweight Markdown editor with live preview came to be.

Zusammenfassung

The md-editor offers a lightweight and highly customizable solution for Markdown editing without complex build processes. By making a conscious decision to use a textarea-based approach, the tool enables maximum control over rendering and integration into proprietary systems. Special features such as live preview and the correct rendering of frontmatter support an efficient writing workflow.


Diese Zusammenfassung wurde mit KI-Unterstützung erstellt.

Why a custom Markdown editor?

There are many Markdown editors out there, and some of them are excellent projects. There was no compelling reason to build another one—and certainly no claim to being able to do it better.

What didn't convince me about the market wasn't the quality of these tools, but the fit. A finished solution comes with its own update cycle, its own CSS, and its own idea of what a Markdown editor should do or how it should behave. If I want to render the preview differently, treat the frontmatter block separately, or later integrate a direct connection to an SSG build process, you quickly reach the point where you have to dive very deep into someone else's code. And especially with mature projects, this process of understanding and adapting often ends up costing more time than a deliberately slim new development tailored to your own use case.

That was the real trigger: laying the foundation for a component that I know completely, control completely, and can evolve in any direction. Not as a counter-concept to existing projects, but as a conscious decision for more control within my own system.


Requirements in plain English

What the editor needs to do was written down quickly:

  • Toolbar with the usual Markdown actions
  • Live preview, toggleable, not always visible
  • Ability to display original language and translation side-by-side
  • Frontmatter displayed correctly in the preview – not rendered as Markdown
  • Fullscreen mode for focused writing
  • Integration via <script> and <link> – ready to go, without a build step

The decision: <textarea> instead of contenteditable

The first fundamental architectural principle: The editor is based on a standard <textarea>, not on a contenteditable element.

This is a deliberate limitation. contenteditable allows for richer interactions—for example, clickable links, visual formatting directly in the text, or very free cursor operations. At the same time, however, this approach brings significantly more complex behavior in practice regarding selection, input, pasting, and DOM manipulation compared to a classic <textarea>. Anyone who has worked intensively with it knows this issue.

A <textarea>, on the other hand, behaves much more predictably. Selections, cursor positions, and text manipulation run via selectionStart, selectionEnd, and value. This is testable, traceable, and significantly more consistent across major browsers.

This is the compromise: no inline highlighting and no rich-text illusion. In exchange, the same editor code can generally be operated much more consistently across different browsers.


IIFE wrapper and why not ES modules

At this point, it was no longer about Markdown, but about the method of integration in the browser. The question was: Should the editor be built as a modern ES module or as a classic file that can be attached directly to a page via <script>?

I consciously chose the second option. The editor should be immediately usable without a build step, without a bundler, and without a module setup. This is exactly what an IIFE wrapper is suited for—a function that is executed immediately upon its definition and wraps its own code in a clean scope.

(function(global) {
  "use strict";
  // ... MdEditor class ...
  global.MdEditor = MdEditor;
})(window);

Why no export default MdEditor? Because export and import belong to the module syntax and only work in a module context in the browser. Anyone who simply includes a file via <script src="md-editor.js"></script> is initially loading a classic script—not an ES module. However, the goal here was very deliberately this simple integration: without type="module", without import paths, and without a bundler context. Therefore, the IIFE makes MdEditor available globally on window, similar to how many classic browser libraries operate.


The hardest part: _toggleLinePrefix

Most toolbar actions are relatively simple: select text, insert wrapping characters, update selection. That is quickly built.

The prefix toggle, however, is not trivial. What should happen if someone clicks H2 and the line already starts with ###? Or if you click Bullet List and the line is currently ## Title?

The naive behavior would be: simply prepend ##. Then you would have ## ### Title. Technically easy, but practically useless.

The clean solution:

  1. Determine the line (line start via lastIndexOf("\n") backwards)
  2. Check if the desired prefix is already present—if so, remove it
  3. If not: remove existing prefix, then set the new one

The third step is the crucial part. A replace(/^(#{1,6} |- |\d+\. )/, "") at the start of the line removes any existing heading and list prefixes in this editor. After that, only the new prefix is set. Clicking H2 on an H3 line therefore turns it into H2—not ## ### line.

At the same time, the cursor offset must be updated cleanly. If a prefix becomes shorter or longer, the cursor position shifts accordingly. Calculating this cursorDelta correctly and applying it to selectionStart as well as selectionEnd was ultimately the part that required the most care.


Frontmatter in the preview

Part of the Markdown files the editor is intended for begin with a YAML frontmatter block:

---
title: "My Post"
date: 2026-04-06
---

# The actual text

This block contains metadata such as title or date and is a completely normal part of the file in many static website or blog setups. For the preview, however, this is a special case: the block should remain visible but should not be rendered like normal Markdown content.

In marked.js, there is no built-in frontmatter support for this. Without preprocessing, the block would therefore not be treated as a metadata area, but interpreted as normal Markdown content. For a preview that is intended to reflect the actual file structure, this doesn't fit.

The solution is therefore a pre-processing step: A regex FRONTMATTER_RE checks the beginning of the content. If a frontmatter block is recognized there, it is extracted from the text and displayed separately as a styled <div class="mde-fm-block">. The remaining content then passes through marked.parse(). In the preview, this results in a clearer picture: metadata at the top, followed by the actual Markdown content below.


Fullscreen and the global instance tracker

The fullscreen mode itself is not technically complicated: the editor places itself over the entire viewport using position: fixed.

It gets more interesting as soon as multiple MdEditor instances are running on the same page. If Instance A switches to fullscreen mode, Instances B and C should, of course, not also remain in fullscreen. That would be visually and logically messy. The solution is a module-wide Set _instances that manages all active instances. When an instance calls _toggleFullscreen(), it iterates over _instances and terminates the fullscreen mode for all others.

The same Set also serves as the basis for the global Escape handler: a single event listener on document.keydown—not one per instance—iterates over all instances and calls _exitFullscreen() as soon as Escape is detected.

The destroy() method cleans up at the end: removes the instance from _instances, clears DOM elements, and removes event listeners.


The CSS: Custom Properties all the way through

The theming concept was clear from the start: no hardcoded hex values in the design process, no Sass variables as a prerequisite, but CSS Custom Properties. All color tokens are defined on .mde:

.mde {
  --mde-bg:         #1a1a2e;
  --mde-accent:     #7c6af7;
  /* … */
}

Three theme modes:

  • Dark (Default): .mde – always dark, regardless of what the OS says
  • Light (Explicit): .mde.mde-light – always light
  • Auto: .mde.mde-auto + @media (prefers-color-scheme: light) – follows the OS

This means: anyone integrating the editor into a dark-mode app doesn't have to do anything else. Anyone integrating it into a light interface adds .mde-light to the container class. And anyone who wants to adjust colors can overwrite the custom properties in their own CSS—without touching the editor itself.

Sebastian Software Engineer &amp; Wildlife Photographer
← ← Back to blog