---
title: "Markdown Editor"
type: plugin
version: "2.3.0"
status: stable
date: 2026-10-11
library: Frutjam
stack: tailwind_css
compatibility: universal
framework_agnostic: true
runtime_requirement: none
description: "Lightweight JavaScript markdown editor that enhances your textarea without replacing it. Works as-is with Django, Laravel, Rails, PHP and Node.js. WYSIWYG, RTL."
url: https://frutjam.com/plugins/markdown-editor
---

# Toolbar, live preview and WYSIWYG on the textarea you already have

A markdown editor that enhances your textarea instead of replacing it, so form submission, validation and required fields keep working. WYSIWYG and plain Markdown modes, live preview, find & replace, RTL support, dark mode. Works standalone with Django, Laravel, Rails, Node.js, PHP, and any stack.

**No Frutjam and no Tailwind required.** The styles are bundled, so the editor drops into any project.

## Installation

#### NPM (bundlers: Vite, webpack, Rollup, etc.)

```bash
npm install markdown-text-editor
```

```javascript
import MarkdownEditor from 'markdown-text-editor';
new MarkdownEditor('#markdown-editor');
```

TypeScript definitions ship with the package, so options, toolbar entries and variable shapes are checked and autocompleted with no extra install.

The types are importable too, which matters whenever your options are not written inline at the call site. TypeScript only checks for unknown keys on an object literal passed directly, so a config kept in its own variable has a misspelled key silently ignored. Annotating it puts the check back.

```typescript
import MarkdownEditor, { type MarkdownEditorOptions, type ToolbarEntry } from 'markdown-text-editor';

// Without the annotation this compiles, the editor renders with the default
// toolbar, and nothing tells you why.
const config: MarkdownEditorOptions = {
    placeholder: 'Write...',
    toolbr: ['bold', 'preview'],   // Error: did you mean to write 'toolbar'?
};

// Building a toolbar conditionally
const toolbar: ToolbarEntry[] = ['bold', 'italic'];
if (canUpload) toolbar.push({ image: { fileInput: { uploadUrl: '/api/upload' } } });
toolbar.push('preview');
```

The same applies to a wrapper component, where `MarkdownEditorOptions` is what you forward instead of `any`, so whoever uses your wrapper keeps the checking.

```typescript
interface EditorProps {
    value: string;
    options?: MarkdownEditorOptions;
}
```

#### CDN: ES module

```html
<script type="module">
  import MarkdownEditor from 'https://cdn.jsdelivr.net/npm/markdown-text-editor/+esm';
  new MarkdownEditor('#markdown-editor');
</script>
```

#### CDN: global script tag (IIFE)

No import needed: MarkdownEditor is available as a global variable automatically.

```html
<form action="/api/save" method="POST">
  <textarea id="markdown-editor" name="content"># Hello World</textarea>
  <button type="submit">Save Content</button>
</form>

<script src="https://cdn.jsdelivr.net/npm/markdown-text-editor"></script>
<script>
  new MarkdownEditor('#markdown-editor');
</script>
```

## Markdown Editor Demo

## Quick Start

Pass an options object to customise the editor. All options are optional: omit any to use the default value.

```javascript
const editor = new MarkdownEditor('#markdown-editor', {
  placeholder: 'Write your markdown...',
  toolbar: ['heading', 'bold', 'italic', 'strikethrough', 'ul', 'ol', 'checklist', 'blockquote', 'link', 'preview'],
});
```

## The Philosophy: "Native-First"

Most editors break the standard web workflow. **MarkdownEditor** embraces it. Because it sits directly on top of a `<textarea>`, you don't need to learn a new way to handle data.

-   **No Data Binding Needed:** Works with `<form method="POST">` out of the box
-   **Standard Access:** Use `document.getElementById('editor').value` just like a normal input.
-   **Backend Agnostic:** Works with any backend (Python, Node.js, PHP, etc.) just like a normal form field

## MarkdownEditor vs EasyMDE / SimpleMDE

Most JavaScript markdown editors (EasyMDE, SimpleMDE, CodeMirror-based editors) **hide your `<textarea>`** and edit a copy, writing the content back when the form is submitted. That works until something else needs the value: a `required` field the browser cannot focus blocks the submit entirely, and `.value` or `FormData` read before submit returns an empty string, which breaks htmx, Turbo, autosave and unsaved-changes guards. **MarkdownEditor is different.** It styles the textarea you already have and leaves it as the field you type into, so the value is correct at every moment.

| Feature | MarkdownEditor | EasyMDE / SimpleMDE |
| --- | --- | --- |
| Native textarea preserved | ✅ | ❌ Hidden, edited as a copy |
| Works with `required` fields | ✅ | ❌ Browser blocks the submit |
| `.value` correct before submit | ✅ | ❌ Empty until the form submits |
| Serialises with `FormData`, htmx, Turbo | ✅ | ❌ Needs the editor's own API |
| WYSIWYG hybrid mode | ✅ | ❌ |
| Built-in Find & Replace | ✅ | ❌ |
| RTL support | ✅ Built in | Via CodeMirror's `direction` option |
| Inline event handlers (CSP) | ✅ None | Some |
| Dark mode / theming | ✅ | Limited |
| Bundle size, gzipped | 53 KB | 107 KB (JS + CSS) |

## Framework Integration

Because **MarkdownEditor** preserves the native `<textarea>`, it integrates with every backend framework without any extra code. Your server receives the markdown content exactly as it would from any standard form field. For React and Vue it takes a few lines, covered at the end of this section.

### Django

Add a `class` to your textarea widget and initialize the editor: `request.POST['content']` works with no extra steps.

```python
# forms.py
class PostForm(forms.ModelForm):
    class Meta:
        model = Post
        fields = ['content']
        widgets = {
            'content': forms.Textarea(attrs={'class': 'markdown-editor'}),
        }
```

```javascript
new MarkdownEditor('.markdown-editor');
// request.POST['content'] contains the markdown on submit
```

### Laravel

Use `f.text_area` with a class: `$request->input('content')` receives the markdown directly.

```html
<textarea name="content" class="markdown-editor">{{ old('content') }}</textarea>
<script>new MarkdownEditor('.markdown-editor');</script>
```

### Ruby on Rails

Works with `form_with` out of the box: `params[:content]` contains the markdown. For Turbo Drive, use `turbo:load` instead of `DOMContentLoaded`.

```javascript
document.addEventListener('turbo:load', () => {
    document.querySelectorAll('.markdown-editor:not([data-mde-init])').forEach(el => {
        el.setAttribute('data-mde-init', 'true');
        new MarkdownEditor(el);
    });
});
```

### Node.js / Express

`req.body.content` receives the markdown on form submit: no sync step, no custom extraction.

```html
<textarea name="content" class="markdown-editor"></textarea>
<script src="https://cdn.jsdelivr.net/npm/markdown-text-editor"></script>
<script>new MarkdownEditor('.markdown-editor');</script>
```

### PHP

`$_POST['content']` works exactly as with any standard textarea: drop it in and your existing form handling requires zero changes.

```html
<form method="POST" action="save.php">
  <textarea name="content" class="markdown-editor"></textarea>
  <button type="submit">Save</button>
</form>
<script src="https://cdn.jsdelivr.net/npm/markdown-text-editor"></script>
<script>new MarkdownEditor('.markdown-editor');</script>
```

### React

Create the editor in an effect and destroy it on unmount. Use `defaultValue` rather than `value`: the editor writes to the textarea directly, so a controlled binding would overwrite what the user is typing.

```javascript
import { useEffect, useRef } from 'react';
import MarkdownEditor from 'markdown-text-editor';

function MarkdownField({ name, defaultValue = '', onChange }) {
    const ref = useRef(null);

    useEffect(() => {
        const editor = new MarkdownEditor(ref.current, { onChange });
        return () => editor.destroy();
    }, []);

    return <textarea ref={ref} name={name} defaultValue={defaultValue} /&gt;;
}
```

Returning `editor.destroy()` from the effect also covers StrictMode, which runs effects twice in development and would otherwise leave two editors on one textarea. The empty dependency array is deliberate: options are read once when the editor is created, so re-running the effect would tear it down and rebuild it on every change.

### Vue

Same idea. Set the initial value once in `onMounted` and do not bind `:value`, or every keystroke emitted back through `v-model` would overwrite the textarea.

```javascript
<script setup&gt;
import { ref, onMounted, onBeforeUnmount } from 'vue';
import MarkdownEditor from 'markdown-text-editor';

const el = ref(null);
const props = defineProps({ modelValue: { type: String, default: '' } });
const emit = defineEmits(['update:modelValue']);
let editor = null;

onMounted(() => {
    el.value.value = props.modelValue;
    editor = new MarkdownEditor(el.value, {
        onChange: value =&gt; emit('update:modelValue', value)
    });
});

onBeforeUnmount(() =&gt; editor?.destroy());
</script&gt;

<template&gt;<textarea ref="el"&gt;</textarea&gt;</template&gt;
```

Used as `<MarkdownField v-model="content" />`. In a plain form you can skip `v-model` entirely and read the value from the textarea on submit, as with any other framework.

## Configuration

You can fully customize the editor's behavior and interface by passing an `options` object. If you omit an option, the **default** value is used.

Options are read once, when the editor is constructed. Changing them afterwards has no effect: call `destroy()` and create a new editor instead.

| Property | Type | Default | Purpose |
| --- | --- | --- | --- |
| `mode` | `string` | `'plain'` | Sets the initial view. Use hybrid for a WYSIWYG experience or plain for raw syntax. |
| `placeholder` | `string` | `'Write...'` | Text shown when the editor is empty. |
| `toolbar` | `array` | `[...]` | Defines which tools appear and in what order. |
| `footer` | `false | object` | all visible | Controls the status bar shown below the editor. Set to `false` to hide it entirely, or pass an object to toggle individual stats. |
| `theme` | `string` | inherited | Explicitly sets the editor theme (light, dark, snowberry, darkberry). If omitted, the editor inherits `data-theme` from the nearest ancestor element or the `<textarea>` itself. |
| `minHeight` | `number` | `200` | Minimum height in pixels the editor will shrink to when content is short. Pairs with `maxHeight` to set the auto-grow range. |
| `maxHeight` | `number` | `500` | Maximum height in pixels the editor can grow to in non-fullscreen mode. Once content exceeds this height a scrollbar appears inside the editor. The editor also has a drag handle so users can manually resize it beyond this limit. |
| `renderer` | `function` | `marked` | Replaces the markdown parser used for the preview. Receives the markdown string and must return an HTML string. |
| `sanitizer` | `function` | `DOMPurify` | Replaces the HTML sanitizer. Receives the rendered HTML and must return the safe HTML to display. |
| `onChange` | `function` | `undefined` | Callback fired on every content change: typing, toolbar actions, undo/redo, and list continuation. Receives the current markdown string as its only argument. |
| `labels` | `object` | `undefined` | Translations for the editor's own interface, keyed by the English string. Anything you leave out stays in English. |

### 🛠 Toolbar Customization

The toolbar is modular. You can create a minimal experience or a full-featured power suite by modifying the array.

#### Available Tools

| Category | Tool Keys |
| --- | --- |
| Typography | `heading`, `bold`, `italic`, `strikethrough`, `blockquote` |
| Lists | `ul` (bullet), `ol` (numbered), `checklist` |
| Code | `code` (inline), `codeblock` (fenced block) |
| Inserts | `hr` (horizontal rule), `table` (table template) |
| Media | `link`, `image` |
| Editing | `undo`, `redo`, `indent`, `outdent` |
| View | `preview` |
| Templates | `{ variables: [...] }`, configured inline |

#### Tool Reference

| Tool | Description |
| --- | --- |
| `heading` | Opens a dropdown to select heading level H1–H6. Narrow the list with `{ heading: { levels: [2, 3, 4] } }` |
| `bold` | Enables bold text formatting. |
| `italic` | Enables italic text formatting. |
| `strikethrough` | Allows for text strikethrough. |
| `ol` | (Ordered List): Converts text into a numbered list format. |
| `ul` | (Unordered List): Converts text into a bullet point list. |
| `checklist` | Adds checkboxes to your text, making it great for tasks, to-do lists, or tracking completion status. |
| `blockquote` | Highlight quoted or emphasized text. |
| `code` | Wraps selected text in single backticks for inline code. Clicking again removes the backticks. |
| `codeblock` | Wraps selected text in a triple-backtick fenced code block. Clicking again removes the fences. |
| `hr` | Inserts a `---` horizontal rule at the cursor position on its own line. |
| `table` | Inserts a starter 2x3 markdown table template at the cursor position. |
| `image` | Allows you to insert images via markdown syntax. |
| `link` | Lets you add hyperlinks to your text. |
| `undo` | To reverse the last changes. |
| `redo` | To reapply the last undone changes. |
| `indent` | To increase the indentation level. |
| `outdent` | To decrease the indentation level. |
| `preview` | Toggles a fullscreen side-by-side preview. Checkboxes in the preview pane are clickable and update the markdown source instantly. Press Escape to exit fullscreen. If a fixed header covers the editor in fullscreen, see `--mte-fullscreen-z-index`. |

#### Heading levels

The `heading` dropdown offers H1 to H6. Where H1 belongs to the page rather than the content, which is the case for most CMS body fields, narrow it:

```javascript
new MarkdownEditor('#markdown-editor', {
    toolbar: [{ heading: { levels: [2, 3, 4] } }, 'bold', 'italic', 'preview']
});
```

`Ctrl+1`, `Ctrl+2` and `Ctrl+3` follow the same list, so a level you leave out cannot be inserted from the keyboard either, and the button's tooltip lists only the shortcuts that still work. The order you write them in does not matter, the menu is always H1 first. Levels outside 1 to 6 are ignored with a console warning, and a list with nothing usable in it falls back to all six rather than rendering an empty menu.

This restricts the toolbar, not the document. Someone can still type `# Title` by hand and it renders as a heading, because what is valid markdown is the renderer's decision rather than the toolbar's. If the rule has to hold, enforce it on the server.

##### 💡 Implementation Tips:

-   **Reordering**: The buttons appear in the exact order you list them in the array
-   **Removing**: Simply omit any key (like `image`) from the array to disable that feature entirely for the user
-   **Native Fallback**: If you don't provide a `placeholder` in JS, the plugin will automatically use the `placeholder` attribute from your HTML `<textarea>`

### 🧩 Custom Tools

Add a toolbar button of your own without forking. Describe it and drop it into the `toolbar` array, in whatever position you want it.

```javascript
new MarkdownEditor('#markdown-editor', {
    toolbar: ['bold', 'italic', {
        custom: {
            title: 'Callout',
            icon: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20">…</svg>',
            action(editor) {
                editor.insertText('> [!NOTE]\n> Something worth noting');
            }
        }
    }, 'preview']
});
```

The `title` becomes the tooltip and the button's CSS class, so this one is styleable as `.callout-btn`, exactly like the built-in tools.

#### Several tools

Add as many as you like. Each one is its own entry in the array, so you place them individually and they can sit between built-in tools rather than being grouped at one end.

```javascript
toolbar: [
    'bold', 'italic',
    { custom: { title: 'Callout',   icon: svg1, action: insertCallout } },
    'ul', 'ol',
    { custom: { title: 'Signature', icon: svg2, action: insertSignature } },
    'preview'
]
```

Give each one a distinct `title`. Two tools sharing a title would share a CSS class, and a tool without one falls back to `.custom-btn`.

#### Options

| Property | Type | Purpose |
| --- | --- | --- |
| `title` | `string` | Required. Tooltip text, and the source of the button's CSS class. Translatable through `labels`. |
| `action` | `function` | Required. Receives `(editor, event)`. The click event is passed on so a tool that opens a dialog can hand it to `MarkdownEditor.modal`. |
| `icon` | `string` | Inline SVG for the button face. |
| `shortcut` | `string` | A keyboard shortcut such as `Ctrl+Shift+K`. Added to the tooltip, and takes precedence over a built-in using the same keys. |

A tool with no `action` is skipped with a console warning rather than breaking the toolbar. It renders no button, and its `shortcut`, if it declared one, is not registered either.

#### Working with the content

Use `editor.insertText()` rather than writing to the textarea yourself. It replaces the selection, restores focus, moves the caret, scrolls, re-renders the preview and fires `onChange`. Undo keeps working because the edit goes through the normal path.

```javascript
action(editor) {
    const textarea = editor.usertextarea;
    const selected = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd);
    const wrapped = `==${selected || 'highlight'}==`;

    // (text, where the caret lands, characters to leave outside the selection)
    editor.insertText(wrapped, wrapped.length, 0);
}
```

#### Shortcuts

```javascript
{ custom: {
    title: 'Highlight',
    icon: '<svg>…</svg>',
    shortcut: 'Ctrl+Shift+H',
    action: (editor) => editor.insertText('====', 2, 2)
}}
```

The tooltip becomes "Highlight (Ctrl+Shift+H)". Tool shortcuts are checked before the built-in ones, so a custom tool can deliberately take over a combination the editor would otherwise handle.

#### Tools that need their own markup

A dropdown or a dialog cannot be described declaratively, so write a class instead. Extend `MarkdownEditor.Tool`, set `this.button` in the constructor, and implement `applySyntax()`.

```javascript
class EmbedTool extends MarkdownEditor.Tool {
    constructor(editor) {
        super(editor, 'Embed video');        // tooltip, and .embed-video-btn
        this.shortcut = 'Ctrl+Shift+E';      // optional
        this.button = this.createButton('<svg>…</svg>');
    }

    applySyntax(event) {
        const dialog = MarkdownEditor.modal(event, 'fj:max-w-sm', `
            <input type="url" class="embed-url fj:me-input fj:w-full">
            <button type="button" class="embed-go fj:me-btn fj:me-btn-sm">Insert</button>
        `, 'Embed');

        dialog.querySelector('.embed-go').addEventListener('click', () => {
            const url = dialog.querySelector('.embed-url').value.trim();
            if (url) this.editor.insertText(`\n<iframe src="${url}"></iframe>\n`);
            dialog.close();
        });
    }

    destroy() {
        // Called by editor.destroy(). Release anything you registered here.
    }
}

new MarkdownEditor('#markdown-editor', { toolbar: ['bold', EmbedTool, 'preview'] });
```

Pass configuration with `{ tool: EmbedTool, config: {...} }`, which arrives as the constructor's second argument. Setting `this.button` to `null` renders nothing, which is useful for a tool that only applies in some configurations.

One thing to watch: `Tool`'s constructor calls `createButton()`, so anything your override needs must come from `this.editor`, not from a field you assign after `super()`.

#### The dialog helper

`MarkdownEditor.modal` is the dialog the built-in link and image tools use, so a tool of yours that collects input looks like the rest of the editor instead of inventing its own popup. It returns the `<dialog>` element, already open, which you query for your fields and close with `dialog.close()`.

```javascript
// (event, size classes, body HTML, aria label)
const dialog = MarkdownEditor.modal(event, 'fj:max-w-sm', '…', 'Embed');
```

Pass the `event` your `applySyntax()` received. The helper reads it to find which editor the click came from, so the dialog is appended inside that editor's wrapper and picks up its theme. It replaces any dialog already open in that editor, and removes itself from the DOM when closed, so there is nothing to clean up.

#### Rendering markdown yourself

If your tool embeds rendered content, a tooltip body for instance, `editor.renderMarkdown()` produces HTML exactly as the preview does, honouring your `renderer` and `sanitizer`.

```javascript
const html = editor.renderMarkdown('**bold** text');
```

#### A toolbar for your own textarea

When a dialog collects rich text, give it the real formatting tools instead of rebuilding them. The toolbar is bound to the textarea you pass, your `labels` are inherited, and no preview button is added.

```javascript
const body = dialog.querySelector('textarea');
const bar = editor.createToolbar(body, ['bold', 'italic', 'strikethrough']);
dialog.querySelector('.body-toolbar').appendChild(bar);
```

This does not manage the toolbar's lifetime. The buttons are cleaned up with the dialog when it is removed, but if a tool in it registers anything page-wide, a document listener or a timer, release that yourself when the dialog closes.

#### Translation

Custom tools translate like built-in ones, keyed by the title you gave them.

```javascript
labels: { 'Callout': 'Aviso', 'Embed video': 'Insertar video' }
```

The CSS class is unaffected, so `.callout-btn` stays `.callout-btn` in every language.

### 📊 Footer (Status Bar)

The footer sits below the editor and shows the cursor's **line**, **column**, the document's **character count**, and optionally the **word count**, all updated in real time. It is visible by default and each stat can be toggled independently.

| Key | Type | Default | Description |
| --- | --- | --- | --- |
| `line` | `boolean` | `true` | Show the current line number. |
| `col` | `boolean` | `true` | Show the current column number. |
| `chars` | `boolean` | `true` | Show the total character count. |
| `words` | `boolean` | `false` | Show the total word count. Off by default: set to `true` to enable. |

#### Usage Examples

```javascript
// Default — line, col, and chars visible
new MarkdownEditor('#editor');

// Disable the footer entirely
new MarkdownEditor('#editor', { footer: false });

// Hide only character count
new MarkdownEditor('#editor', { footer: { chars: false } });

// Hide line and column, keep character count
new MarkdownEditor('#editor', { footer: { line: false, col: false } });

// Show only line number
new MarkdownEditor('#editor', { footer: { col: false, chars: false } });

// Enable word count alongside the defaults
new MarkdownEditor('#editor', { footer: { words: true } });

// Show word count only
new MarkdownEditor('#editor', { footer: { line: false, col: false, chars: false, words: true } });
```

### 🔀 Editing Modes

**MarkdownEditor** offers two distinct ways to write and format your content. You can toggle between a traditional syntax-focused view or a modern, visual-first experience.

-   `plain` (**Default**): A clean, high-performance Markdown environment where syntax (like `**bold**` or `# heading`) is visible. Ideal for developers and Markdown purists
-   `hybrid`: A WYSIWYG-inspired experience that renders formatting (Bold, Italics, Headings) in real-time as you type, while still maintaining the underlying Markdown structure.

```javascript
// Default initialization (Plain Mode)
new MarkdownEditor('#markdown-editor');

// Explicit Plain Mode
new MarkdownEditor('#markdown-editor', {
    mode: 'plain'
});

// Hybrid (Visual) Mode
new MarkdownEditor('#markdown-editor', {
    mode: 'hybrid'
});
```

#### Hybrid and Plain Mode Preview:

##### Hybrid Mode

Visual formatting is rendered in real-time while you type.

##### Plain Mode (Default)

Focuses on raw Markdown syntax for a lightweight experience.

### 🌙 Theming

**MarkdownEditor** automatically inherits its theme from the surrounding page: no configuration required. The editor reads `data-theme` from the nearest ancestor at initialisation, so it stays in sync with your site's theme out of the box.

#### How theme is resolved (priority order)

1.  **`theme` option**: explicit override passed in the options object
2.  **`data-theme` on the `<textarea>`**: set directly on the element
3.  **`data-theme` on any ancestor**: e.g. `<html>`, `<body>`, or a wrapper `<div>`

#### Available themes

`'light'` (default), `'dark'`, `'snowberry'`, `'darkberry'`

##### Option 1, inherit from `<html>` or any ancestor (zero config)

```html
<html data-theme="dark">
  ...
  <textarea id="markdown-editor"></textarea>
  <script>
    new MarkdownEditor('#markdown-editor'); // picks up dark automatically
  </script>
```

##### Option 2: set `data-theme` directly on the `<textarea>`

```html
<textarea id="markdown-editor" data-theme="dark"></textarea>
<script>
  new MarkdownEditor('#markdown-editor');
</script>
```

##### Option 3: explicit `theme` option (overrides everything)

```javascript
new MarkdownEditor('#markdown-editor', {
    theme: 'dark'
});
```

#### 🎨 Custom Theme via CSS Variables

You can fully customize the editor's look by overriding its CSS variables on the `.markdown-editor-wrapper` element or any `[data-theme]` selector. All colors use the [OKLCH color space](https://oklch.com) for perceptually uniform results.

| Variable | Purpose | Light default | Dark default |
| --- | --- | --- | --- |
| `--color-base` | Editor background | `oklch(100% 0 0)` | `oklch(10.9% 0 0)` |
| `--color-on-base` | Primary text color | `oklch(22% 0 0)` | `oklch(98% 0 0)` |
| `--color-primary` | Primary accent (toolbar active, links) | `oklch(51.1% .262 277)` | `oklch(66.4% .184 286)` |
| `--color-on-primary` | Text on primary-colored surfaces | `oklch(96.2% .018 272)` | `oklch(10% .01 270)` |
| `--color-secondary` | Secondary accent | `oklch(59.1% .293 323)` | `oklch(65% .18 220)` |
| `--color-accent` | Highlight accent (inline code, italic) | `oklch(54.1% .281 293)` | `oklch(75% .18 50)` |
| `--color-neutral` | Neutral surfaces (borders, dividers) | `oklch(15% 0 0)` | `oklch(85% 0 0)` |
| `--color-error` | Error state color | `oklch(57.7% .245 27)` | `oklch(60% .22 30)` |
| `--border-radius` | Corner rounding of the editor frame | `0.25rem` |

##### Custom theme example

Override any variable on `.markdown-editor-wrapper` after the editor initialises, or define a custom `[data-theme]` block in your stylesheet:

```css
/* Override individual variables */
.markdown-editor-wrapper {
    --color-primary: oklch(60% 0.2 30);   /* orange accent */
    --border-radius: 0.5rem;
}

/* Or define a full custom theme */
[data-theme="brand"] .markdown-editor-wrapper,
.markdown-editor-wrapper[data-theme="brand"] {
    --color-base:       oklch(15% 0.01 250);
    --color-on-base:    oklch(95% 0 0);
    --color-primary:    oklch(65% 0.22 145);   /* green */
    --color-on-primary: oklch(10% 0 0);
    --color-accent:     oklch(75% 0.18 60);
    --color-neutral:    oklch(80% 0 0);
    --border-radius:    0.75rem;
}
```

```javascript
new MarkdownEditor('#markdown-editor', { theme: 'brand' });
```

### 🌐 Interface Language

The editor's own text, tooltips, menu items, modal fields and buttons, is English by default. Pass a `labels` object to translate it. Keys are the English strings, the way gettext works, so you only list what you want to change and anything missing falls back to English.

```javascript
new MarkdownEditor('#markdown-editor', {
    labels: {
        'Bold': 'Gras',
        'Italic': 'Italique',
        'Heading': 'Titre',
        'Link': 'Lien',
        'Image link': 'Lien de l\'image',
        'URL': 'URL',
        'Alt text': 'Texte alternatif',
        'Apply': 'Appliquer',
        'Uploading...': 'Téléversement...',
    },
});
```

This matters most alongside right-to-left content. Arabic, Urdu and Farsi lay out correctly on their own, but a toolbar that still reads "Bold" over right-to-left text is only half translated.

### 🏷 Variables

Adds a toolbar dropdown for inserting placeholders. Useful when the person writing a document is not the developer who defined the placeholder syntax, as with email, invoice or contract templates: they pick a readable name and the correct syntax is inserted for them.

```javascript
new MarkdownEditor('#markdown-editor', {
    toolbar: ['bold', 'italic', 'link',
        { variables: [
            { label: 'Customer Name', value: '{{customer.name}}' },
            { label: 'Invoice No',    value: '{{invoice.number}}' }
        ]},
        'preview'
    ]
});
```

The tool is configured inline in the `toolbar` array, so its position among the other buttons is up to you. The button lists the labels, and clicking one inserts its `value` at the cursor, replacing any selection. Nothing is rendered if the list is empty.

#### Sample values in the preview

An entry given a `sample` shows that sample in the preview, while the textarea keeps the real placeholder. Entries without one appear as written, so you can mix both.

```javascript
{ variables: [
    { label: 'Customer Name', value: '{{customer.name}}', sample: 'Hannes' },
    { label: 'Invoice No',    value: '{{invoice.number}}' }
]}

// textarea : Hi {{customer.name}}, invoice {{invoice.number}}
// preview  : Hi Hannes, invoice {{invoice.number}}
```

#### Grouping

Give an entry `items` instead of a `value` to render a headed section. Grouped and flat entries can be mixed in one list.

```javascript
{ variables: [
    { label: 'Today', value: '{{today}}', sample: '10 September 2026' },
    { label: 'Customer', items: [
        { label: 'Name',  value: '{{customer.name}}',  sample: 'Hannes' },
        { label: 'Email', value: '{{customer.email}}', sample: 'hannes@example.com' }
    ]},
    { label: 'Invoice', items: [
        { label: 'Number', value: '{{invoice.number}}' }
    ]}
]}
```

#### Things to know

-   **The editor never resolves variables.** It inserts text, and your application substitutes real values later, usually server-side. `sample` only affects what the preview displays.
-   **Labels are shown as plain text**, so markup in a label is never rendered.
-   **Malformed entries are skipped** rather than throwing, and the button is not rendered at all when nothing usable is configured.

### 🖋 Custom Renderer

The preview is rendered with [marked](https://marked.js.org) and sanitized with [DOMPurify](https://github.com/cure53/DOMPurify). Both can be replaced. Use this when your application already renders markdown with another library and you want the preview to match production exactly.

Both options are plain functions that take a string and return a string, so any parser and any sanitizer works.

```javascript
import MarkdownIt from 'markdown-it';
import taskLists from 'markdown-it-task-lists';
import MarkdownEditor from 'markdown-text-editor';

const md = new MarkdownIt({ linkify: true, breaks: true }).use(taskLists);

new MarkdownEditor('#markdown-editor', {
    renderer: markdown => md.render(markdown)
});
```

DOMPurify still runs on the output, so you keep XSS protection without configuring anything.

#### Custom sanitizer

Only needed when the default strips something your renderer emits. DOMPurify removes `<iframe>` by default, so video embeds need it allowed explicitly. DOMPurify must be imported in your own code, since the copy bundled with the editor is internal.

```javascript
import DOMPurify from 'dompurify';

new MarkdownEditor('#markdown-editor', {
    renderer:  markdown => md.render(markdown),
    sanitizer: html => DOMPurify.sanitize(html, {
        ADD_TAGS: ['iframe'],
        ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder']
    })
});
```

Using a script tag instead of a bundler, load DOMPurify alongside the editor:

```html
<script src="https://cdn.jsdelivr.net/npm/dompurify"></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-text-editor"></script>
```

#### Things to know

-   **Preview only.** Hybrid mode's live formatting uses a separate internal renderer and is not affected.
-   **Task lists need plugin support.** Clickable checkboxes are found by looking for `input[type="checkbox"]` in the output, so markdown-it needs `markdown-it-task-lists`. The editor logs a warning if it detects task list syntax and no checkboxes.
-   **Both must be synchronous** and return a string. An `async` function writes `[object Promise]` into the preview.
-   **Replacing the sanitizer replaces your protection.** A pass-through such as `html => html` disables sanitizing entirely and is only safe for fully trusted content.

### 🎨 Styling Internal Elements

CSS variables cover most theming because they inherit, so setting one on `.markdown-editor-wrapper` reaches the toolbar, buttons, preview and footer. Reach for a class name only when no variable exposes what you need.

```css
/* Variables set on the wrapper inherit down to every child */
.markdown-editor-wrapper {
    --border-radius: 12px;   /* rounds the editor and its toolbar buttons */
    --color-primary: oklch(60% 0.2 30);
}
```

#### Class reference

These class names are stable and safe to style against.

| Class | Element |
| --- | --- |
| `.markdown-editor-wrapper` | Outer container wrapping the whole editor |
| `.toolbar` | Toolbar strip above the editing area |
| `.markdown-btn` | Individual toolbar button |
| `.preview-btn` | The preview / fullscreen toggle button |
| `.editor-layout` | Grid holding the editing area and preview side by side |
| `.textarea-wrapper` | Wrapper around the editing area |
| `.editor-textarea` | The underlying textarea element |
| `.display-layer` | Rendered formatting layer, hybrid mode only |
| `.preview-wrapper` | Preview column |
| `.preview-content` | Rendered markdown inside the preview |
| `.editor-footer` | Status bar below the editor |
| `.find-replace-panel` | Find and replace panel |

```css
/* Some variables are set by the component on itself, which beats an
   inherited value. Target the element directly for those. */
.markdown-editor-wrapper .markdown-btn {
    --btn--font-size: 0.875rem;
}

/* And use classes for anything no variable exposes */
.markdown-editor-wrapper .toolbar {
    border-bottom: 2px solid oklch(60% 0.2 30);
}
```

#### Targeting one editor

With several editors on one page, target one of them with `data-editor`. The wrapper mirrors the id of its `<textarea>`, so `<textarea id="notes">` gives you `[data-editor="notes"]`. The id itself stays on the textarea, so `getElementById` keeps working.

```css
/* one editor only */
[data-editor="notes"] {
    --border-radius: 0;
}
```

### 🪟 Fullscreen & Layering (z-index)

In fullscreen the editor uses `z-index: 10000`, which clears the layers most UI frameworks reserve for fixed headers and overlays. If a fixed header or sidebar still covers the editor, raise this value.

Override it with `--mte-fullscreen-z-index`. The value applies only in fullscreen, and it inherits, so setting it on any ancestor covers every editor beneath it.

```css
/* every editor on the page */
.markdown-editor-wrapper {
    --mte-fullscreen-z-index: 20000;
}

/* one section only - the value inherits down */
#admin-panel {
    --mte-fullscreen-z-index: 20000;
}

/* a single editor, by its textarea id */
[data-editor="notes"] {
    --mte-fullscreen-z-index: 20000;
}
```

## Content API

One of the core strengths of **MarkdownEditor** is that it keeps the underlying `<textarea>` perfectly synchronized. Whether you are using a modern JavaScript framework or a traditional backend like **Django, PHP, or Laravel**, the workflow remains simple and native.

### Reading and Writing Content

#### 1. The Native Way (Recommended)

Because the editor enhances a standard textarea, you can use familiar DOM methods. This is the fastest way to interact with your data without learning a new API.

```javascript
// Retrieve content via ID
const markdown = document.getElementById('markdown-editor').value;

// Set content via ID (The editor UI updates automatically)
document.getElementById('markdown-editor').value = "# New Heading Content";
```

#### 2. Using a Variable Reference

If you have a reference to the textarea element, you can use it directly: no library-specific API needed.

```javascript
const textarea = document.getElementById('markdown-editor');

// Retrieve content
const markdown = textarea.value;

// Set content (the editor UI reflects this immediately)
textarea.value = "## Updated via JS";
```

#### 3. Setting initial content server-side

The recommended way to set initial content is directly in the `<textarea>` HTML: this works naturally with every backend framework (Django, Laravel, Rails, PHP, etc.) and the editor renders it automatically on init.

```html
<!-- Recommended: set content server-side -->
<textarea id="markdown-editor"># Hello World</textarea>
```

To read or update content at runtime, use the native `textarea` value. Call `editor.render()` after an update to refresh the preview and hybrid layer.

```javascript
const textarea = document.getElementById('markdown-editor');

// Read
const markdown = textarea.value;

// Update at runtime
textarea.value = '# New content';
editor.render();
```

#### 4. Tearing down the editor: `destroy()`

Call `editor.destroy()` to remove the editor DOM wrapper and restore the original `<textarea>` to its position in the document. Useful in single-page applications when unmounting a view.

```javascript
const editor = new MarkdownEditor('#markdown-editor');

// Remove the editor and restore the plain textarea
editor.destroy();
```

### Reacting to changes with `onChange`

Pass an `onChange` callback to be notified on every content change. Receives the current markdown string.

```javascript
const editor = new MarkdownEditor('#markdown-editor', {
    onChange(value) {
        console.log('Content changed:', value.length, 'characters');
    }
});
```

#### Draft auto-save with `localStorage`

Use `onChange` to save a draft on every keystroke. Restore it by pre-filling the textarea before initialising the editor.

```javascript
const DRAFT_KEY = 'my-page-draft';

// Restore saved draft before init (only if textarea starts empty)
const textarea = document.getElementById('markdown-editor');
const saved = localStorage.getItem(DRAFT_KEY);
if (saved && !textarea.value) textarea.value = saved;

// Save on every change
const editor = new MarkdownEditor('#markdown-editor', {
    onChange(value) {
        localStorage.setItem(DRAFT_KEY, value);
    }
});

// Clear draft after successful form submission
document.querySelector('form').addEventListener('submit', () => {
    localStorage.removeItem(DRAFT_KEY);
});
```

### Form Submission

Because **MarkdownEditor** is built directly on the native `<textarea>`, it is compatible with every backend framework (Django, Laravel, PHP, Ruby on Rails, etc.) right out of the box.

This is where the "Native-First" philosophy shines. You don't need to manually sync data before submitting a form. The browser treats the editor exactly like a standard input field.

```html
<form method="POST" action="/api/submit">
  <textarea id="markdown-editor" name="content" class="h-48" rows="5">
    # Initial Content
  </textarea>

  <button type="submit">Submit to Server</button>
</form>

<script>
  // Just initialize it. That's it.
  new MarkdownEditor('#markdown-editor');
</script>
```

Note: MarkdownEditor plugin initialization mandatory

Just use a standard HTML `<form>`. The `name` attribute on the textarea is what your server will use to identify the content.

##### 🚀 Why it's a Game-Changer for Backends

Since the editor preserves the native `<textarea>` behavior, your server handles the data as a standard string. There is zero extra logic required: no `preventDefault()` and no manual `FormData` construction.

##### 💡 Why this is a "Killer Feature":

Most editors (like Quill, Editor.js, simpleMDE, easyMDE) save data in complex JSON structures. If a developer uses those, they have to rewrite their database schema and their rendering logic.

With **MarkdownEditor**, a developer can take an old website, replace a plain `<textarea>` with your editor, and the **backend doesn't even know it changed**. It just receives the same raw text it always did, but the user gets a 10x better experience.

| Framework / Language | How to access the Markdown content |
| --- | --- |
| PHP | `$_POST['content']` |
| Django | `request.POST.get('content')` |
| Node.js (Express) | `req.body.content` |
| Laravel | `$request->input('content')` |
| Ruby on Rails | `params[:content]` |

## 🖼️ Advanced Image Upload

Handling image uploads natively: rather than relying on slow, memory-heavy Base64 strings, is a significant win for both performance and SEO.

### Paste and drop

Paste a screenshot or drag an image file into the editor and it uploads through the same endpoint as the toolbar button. A placeholder appears immediately so the editor never looks frozen, and it is replaced with the real path once the upload lands. If the upload fails the placeholder is replaced with a visible marker rather than vanishing silently.

This needs `uploadUrl` configured below. Without it, pasting an image does nothing and the editor logs a warning explaining what to add, because the alternative would be a Base64 string in your database.

Several files at once are uploaded one at a time, so the images land in the order they arrived.

### Configuration Options

The image tool supports a `fileInput` configuration to handle direct server uploads.

-   `accept`: Define an array of allowed image formats (e.g., 'webp', 'avif')
-   `uploadUrl`: Specify the backend endpoint where the `File` object will be sent via `POST`
-   `params`: Optional object to send additional data (like CSRF tokens, user IDs, or folder names) alongside the image file

### Usage example (Full Config)

```javascript
const options = {
  placeholder: 'Start writing...',
  toolbar: [
    'link',
    {
      image: {
        fileInput: {
            accept: ['webp', 'avif'], // restrict the image upload format
            uploadUrl: '/api/upload', // Your upload endpoint
            params: {
                _token: 'your_csrf_token_here', // Essential for Laravel/Django
                folder: 'blog_posts'
            }
        },
        // Supports boolean: true/false OR object: { required: true }
        altInput: { required: true }
      }
    },
    'preview'
  ],
}
const editor = new MarkdownEditor('#markdown-editor', options);
```

### 📡 Server Integration

#### The Request

The editor sends a `POST` request as `multipart/form-data`. By default, it includes:

-   `image_file`: The actual file object
-   `image_alt`: The alt text entered by the user
-   ...plus any custom data defined in the `params` object

#### The Required Response

To confirm a successful upload and insert the image into the editor, your server **must** return the following JSON structure:

```json
{
  "success": true,
  "image_path": "https://cdn.yourdomain.com/uploads/image.webp"
}
```

**Note**: Ensure you use the key `image_path` for the URL of the uploaded image.

You may also return `image_alt`, which is used as a suggestion only when the user left the alt field empty. Anything they typed themselves always wins, so a server that derives alt text from the filename or from image recognition can offer it without overwriting a real description.

```json
{
  "success": true,
  "image_path": "https://cdn.yourdomain.com/uploads/image.webp",
  "image_alt": "Two people looking at a laptop"
}
```

On failure, return `success: false` with an `error` or `message` string. That text is surfaced to the user, so make it something they can act on.

### Image Alt Text Validation (`altInput`)

To ensure your content remains accessible and SEO-friendly, **MarkdownEditor** enforces alt text validation by default.

-   **Default Behavior**: If `altInput` is not defined, it defaults to `{ required: true }`
-   **Enforce Accessibility**: Users will be prevented from inserting an image until an alt description is provided

#### 1. Default (No configuration needed)

```javascript
// Alt text is REQUIRED by default
image: {
  fileInput: { uploadUrl: '/api/upload' }
}
```

#### 2. Shorthand (Disable Validation)

If you want to allow images without descriptions, simply set the boolean to `false`.

```javascript
image: {
  altInput: false // Users can now skip the alt text field
}
```

#### 3. Object-based (Explicit)

```javascript
image: {
    altInput: {
        required: false // Disables alt text validation — users can skip the alt field
    }
}
```

### Standard Image Usage (No `fileInput`)

If `fileInput` is not configured, the editor defaults to a simple URL-based modal. This is ideal if your users are mostly linking to external image hosts.

```javascript
const options = {
  toolbar: [
    'link',
    'image',
    'preview'
  ],
}
const editor = new MarkdownEditor('#markdown-editor', options);
```

### 💡 Why use params?

In frameworks like Laravel or Django, you cannot upload files without a CSRF token. By adding `_token` to the `params` object, your request will pass through the backend's security middleware seamlessly, maintaining the "Zero Logic" philosophy for your server-side controllers.

## ⌨️ Keyboard Shortcuts

Common formatting actions can be triggered directly from the keyboard without touching the toolbar. Each shortcut is also shown in the corresponding toolbar button's tooltip.

| Shortcut | Action |
| --- | --- |
| `Ctrl + B`  /  `⌘ B` | Toggle **Bold** |
| `Ctrl + I`  /  `⌘ I` | Toggle *Italic* |
| `Ctrl + K`  /  `⌘ K` | Insert Link |
| `` Ctrl + ` ``  /  `` ⌘ ` `` | Toggle inline `Code` |
| `Ctrl + Shift + S`  /  `⌘ ⇧ S` | Toggle ~~Strikethrough~~ |
| `Ctrl + 1`  /  `⌘ 1` | Heading 1 |
| `Ctrl + 2`  /  `⌘ 2` | Heading 2 |
| `Ctrl + 3`  /  `⌘ 3` | Heading 3. These three follow whatever the `heading` tool offers, so a level you leave out of `levels` cannot be typed either |
| `Ctrl + L`  /  `⌘ L` | Toggle bullet list |
| `Ctrl + Z`  /  `⌘ Z` | Undo |
| `Ctrl + Shift + Z`  /  `⌘ ⇧ Z` | Redo |
| `Tab` | Indent selected lines |
| `Shift + Tab` | Outdent selected lines |
| `Ctrl + F`  /  `⌘ F` | Open Find panel |
| `Ctrl + H`  /  `⌘ H` | Open Find & Replace panel |
| `Ctrl + Shift + F`  /  `⌘ ⇧ F` | Toggle fullscreen preview |
| `F11` | Toggle fullscreen preview |
| `Escape` | Close Find panel / Exit fullscreen preview |

## 🔍 Find & Replace

A built-in find and replace panel is available inside the editor: no browser extension or separate tool needed.

-   Press `Ctrl + F` (or `⌘ F`) to open the **Find** panel
-   Press `Ctrl + H` (or `⌘ H`) to open the **Find & Replace** panel
-   Search is **case-insensitive** and shows a live match counter (e.g. *3 of 12*)
-   Navigate matches with the **▲ / ▼** buttons or `Enter` / `Shift + Enter`
-   **Replace** replaces the current highlighted match; **Replace All** replaces every occurrence at once
-   Press `Escape` to close the panel and return focus to the editor

The panel floats in the top-right corner of the editor content area and does not interrupt writing.

## 🔗 Links from a pasted URL

Select some text, paste a URL over it, and you get a markdown link instead of the URL replacing what you selected.

```markdown
Read the docs                        <- select "the docs", paste https://example.com
Read [the docs](https://example.com) <- what you get
```

The rule is deliberately strict: only a bare `http` or `https` URL with no whitespace is treated this way. Pasting a sentence that happens to contain a URL, or pasting with nothing selected, behaves like an ordinary paste.

## Features

## 🔌 Native Form Integration

Works exactly like a standard <textarea>. No complex APIs: just use the value or name attribute. It "just works" with standard HTML form submissions in PHP, Django, or Node.js.

## 🖼️ Advanced Image Upload

Configure native server uploads via API. Avoid heavy Base64 strings to ensure faster page loads and superior SEO by hosting images on your own CDN.

## 🔀 Hybrid & Plain Modes

Switch between a Hybrid (WYSIWYG) experience for visual editing or Plain Markdown mode for a traditional coding feel.

## 🚀 High Performance

A 53 KB gzipped bundle (252 KB minified, CSS included) optimized for "Heavy Content." Handles massive documents and large files without any input lag or performance drop. Debounced preview updates, cached style calculations, and conflict-free keyboard handling: so Tab and Enter always do exactly one thing.

## 🌍 Built-in RTL Support

Native support for Right-to-Left languages like Arabic, Urdu, and Farsi. Perfect for building globally accessible applications.

## ✨ Syntax Highlighting

Enhanced readability with clear code and markdown formatting.

## 🌙 Adaptive Theming

Includes automatic Dark Mode support. It syncs with your system settings or the Frutjam UI library for a seamless visual experience.

## 📝 Smart Editing

GitHub-style automatic list continuation for ordered lists, unordered lists, and checklists: press Enter and the editor continues the pattern. Checkboxes in the preview pane are clickable and sync back to the markdown source instantly.

## 📱 Fully Responsive

A fluid, mobile-first UI that adapts perfectly to desktops, tablets, and smartphones for editing on the go.

## 📦 Universal Support

Compatible with ESM, UMD, CommonJS, and IIFE. Works out of the box via CDN (<script src>), npm, or any bundler (Vite, webpack, Rollup): no extra configuration needed.

## ♿ Accessible by Default

Full ARIA support built in: toolbar landmark, labelled preview region, screen-reader-friendly buttons, aria-pressed on the preview toggle, disabled and aria-disabled on inactive tools, and correct focus restoration when modals close.

## 🛡️ Zero CSS Conflicts

Editor styles are fully scoped to .markdown-editor-wrapper. Tailwind's global preflight is excluded so the editor lives safely alongside Bootstrap, Tailwind, or any other framework without breaking their styles.

## ⌨️ Keyboard Shortcuts

Ctrl+B, Ctrl+I, Ctrl+K, Ctrl+`, Ctrl+Shift+S: common formatting actions without touching the mouse. Each shortcut is shown in the toolbar button's tooltip.

## 🔍 Find & Replace

Press Ctrl+F to find or Ctrl+H to open find & replace. Case-insensitive search with live match counter, next/prev navigation, single replace, and replace all: without leaving the editor.

## 🔒 XSS Safe Preview

The rendered preview is sanitized via [DOMPurify](https://github.com/cure53/DOMPurify) before being written to the DOM. Script tags, inline event handlers, and malicious URLs in crafted markdown input are stripped automatically: no configuration required.

## ▶️ Real-time Preview

See your markdown rendered instantly as you type.

## 🔗 Easy Integration

Seamlessly integrate into any web project with minimal setup.

## 🛠️ Customizable Toolbar

Dynamically configure and reorder toolbar options like bold, italic, and more.

## Full Configuration Example

Use this comprehensive example to initialize **MarkdownEditor** with all primary features, including custom toolbar ordering and advanced image upload handling.

```javascript
const editor = new MarkdownEditor('#markdown-editor', {
    mode: 'hybrid',
    placeholder: 'Start writing...',
    footer: {
        line: true,
        col: true,
        chars: true,
        words: true,
    },
    onChange(value) {
        console.log('Content updated:', value.length, 'characters');
    },
    labels: {
        'Bold': 'Gras',
        'Italic': 'Italique',
        'Uploading...': 'Téléversement...',
    },
    toolbar: [
        'heading', 'bold', 'italic', 'strikethrough', 'blockquote',
        'ul', 'ol', 'checklist',
        'code', 'codeblock', 'hr', 'table',
        {
            image: {
                fileInput: {
                    accept: ['webp', 'avif', 'png'],
                    uploadUrl: '/api/upload'
                }
            }
        },
        'link', 'undo', 'redo', 'indent', 'outdent', 'preview'
    ],
});

// Read content natively
const markdown = document.getElementById('markdown-editor').value;

// destroy() when the view unmounts (SPAs)
// editor.destroy();
```

Found this useful?

A GitHub star helps other developers discover the editor. It's part of [Frutjam](https://github.com/nezanuha/frutjam). A star there helps too.

[Star on GitHub](https://github.com/nezanuha/markdown-text-editor)
