Markdown Input
The Markdown Input component provides a full-featured markdown editor with syntax highlighting, file upload, autocomplete, and a live status bar.
Installation
npm install @duskmoon-dev/el-markdown-input
Usage
import { register } from '@duskmoon-dev/el-markdown-input';
register();
<form method="post">
<el-dm-markdown-input
name="content"
placeholder="Write markdown…"
></el-dm-markdown-input>
<button type="submit">Save</button>
</form>
Features
The Markdown Input component offers:
- Write/Preview tabs — Switch between syntax-highlighted editor and rendered preview
- Syntax highlighting — Backdrop technique with Prism.js for markdown and code blocks
- File upload — Drag-and-drop, clipboard paste, or file picker
- Autocomplete —
@mentionand#referencewith keyboard navigation - Status bar — Live word and character count with optional max-words cap
- Form participation — Native
<form>submission via ElementInternals - Phoenix LiveView — First-class hook integration for LiveView apps
- Theming — CSS custom properties with light/dark mode support
Live Demo
Markdown Input Editor
Properties
| Property | Type | Default | Description |
|---|---|---|---|
name | string | '' | Form field name for submission |
value | string | '' | Current markdown content |
placeholder | string | 'Write markdown…' | Textarea placeholder text |
disabled | boolean | false | Disables editing |
readonly | boolean | false | Makes the editor read-only (value still submitted with forms) |
upload-url | string | undefined | POST endpoint for file uploads |
max-words | number | undefined | Optional soft word count cap |
dark | boolean | false | Use dark theme variant |
live-preview | boolean | false | Auto-update preview while typing |
debounce | number | 300 | Debounce ms for live preview |
katex-css-url | string | CDN URL | Override KaTeX stylesheet location |
mermaid-src | string | CDN URL | Override mermaid.js source URL |
no-preview | boolean | false | Hide the preview tab; write-only mode |
resize | 'none' | 'vertical' | 'horizontal' | 'both' | 'none' | Enable user resizing of the editor |
Public API
Methods
getValue(): string
// Get the current markdown content
setValue(str: string): void
// Set the markdown content
insertText(str: string): void
// Insert text at the current cursor position
setSuggestions(list: Suggestion[]): void
// Set autocomplete suggestions for dropdown
getFiles(): File[]
// Get locally attached files (when no upload-url is set)
removeFile(index: number): void
// Remove a locally attached file by index
Types
type Suggestion = {
id: string
label: string
subtitle?: string
}
Events
All events bubble and are composed.
| Event | Detail | When |
|---|---|---|
change | { value: string } | On every input change |
upload-start | { file: File } | File accepted for upload |
upload-done | { file: File, url: string, markdown: string } | Upload completed |
upload-error | { file: File, error: string } | Upload failed |
mention-query | { trigger: "@", query: string, resolve: fn } | User typed @word |
reference-query | { trigger: "#", query: string, resolve: fn } | User typed #word |
render-start | {} | Preview render begins |
render-done | { html: string } | Preview render complete |
render-error | { error: Error } | Pipeline or mermaid failure |
Basic Examples
Simple Editor
<el-dm-markdown-input placeholder="Write your post…"></el-dm-markdown-input>
With Form
<form method="post" action="/posts">
<el-dm-markdown-input
name="content"
value="# Hello"
placeholder="Write markdown…"
></el-dm-markdown-input>
<button type="submit">Publish</button>
</form>
With File Upload
<el-dm-markdown-input
upload-url="/api/uploads"
placeholder="Write markdown or paste images…"
></el-dm-markdown-input>
<script>
const editor = document.querySelector('el-dm-markdown-input');
editor.addEventListener('upload-error', (e) => {
console.error(`Upload failed: ${e.detail.error}`);
});
</script>
With Autocomplete
Markdown Input with Autocomplete
const editor = document.querySelector('el-dm-markdown-input');
// Handle @mention queries
editor.addEventListener('mention-query', async (e) => {
const users = [
{ id: 'alice', label: 'Alice Smith', subtitle: 'alice@example.com' },
{ id: 'bob', label: 'Bob Jones', subtitle: 'bob@example.com' },
{ id: 'charlie', label: 'Charlie Brown', subtitle: 'charlie@example.com' }
];
const filtered = users.filter(u =>
u.label.toLowerCase().includes(e.detail.query.toLowerCase())
);
e.detail.resolve(filtered);
});
// Handle #reference queries
editor.addEventListener('reference-query', async (e) => {
const refs = [
{ id: 'bug', label: 'Bug Report' },
{ id: 'feature', label: 'Feature Request' },
{ id: 'docs', label: 'Documentation' }
];
const filtered = refs.filter(r =>
r.label.toLowerCase().includes(e.detail.query.toLowerCase())
);
e.detail.resolve(filtered);
});
Status Bar
The status bar displays word and character counts:
<el-dm-markdown-input max-words="500"></el-dm-markdown-input>
When max-words is set:
- Shows current word count relative to cap (e.g., “142 / 500 words”)
- Status turns amber at 90% of cap
- Status turns red at 100% of cap
- The element reports as form-invalid when the cap is exceeded (native
checkValidity()/reportValidity()work)
Word count algorithm: text.trim().split(/\s+/).filter(Boolean).length
File Upload
File handling works in two modes depending on whether upload-url is set:
| Mode | upload-url | Behavior |
|---|---|---|
| Remote upload | Set | Files are uploaded via XHR and a markdown link is inserted |
| Local attachment | Not set | Files are stored locally and submitted with the form as multipart data |
Remote Upload Mode
Configure the upload-url attribute to upload files to a server endpoint:
<el-dm-markdown-input upload-url="/api/uploads"></el-dm-markdown-input>
The endpoint must:
- Accept
POST multipart/form-datawith field namefile - Respond with JSON:
{ "url": "https://cdn.example.com/file.png" }
Local Attachment Mode (Form Submission)
When no upload-url is set, attached files are included as multipart form data when the parent <form> is submitted:
<form method="post" enctype="multipart/form-data">
<el-dm-markdown-input name="content"></el-dm-markdown-input>
<button type="submit">Submit</button>
</form>
The form will submit:
content— the markdown text valuecontent_files— each attached file (one entry per file)
Files can be managed via the public API:
const editor = document.querySelector('el-dm-markdown-input');
editor.getFiles(); // Returns File[] of locally attached files
editor.removeFile(0); // Remove file at index 0
Accepted File Types
- Images:
image/* - Documents:
.pdf,.txt,.md,.csv,.json - Archives:
.zip
Upload Methods
Users can upload files via:
- Drag and drop onto the write area
- Clipboard paste of images
- File picker button in the status bar
Autocomplete
Trigger Detection
The editor automatically detects @ and # triggers:
// Fires when user types @word
editor.addEventListener('mention-query', (e) => {
// e.detail.query contains the text after @
// Call e.detail.resolve(suggestions) to show dropdown
});
// Fires when user types #word
editor.addEventListener('reference-query', (e) => {
// e.detail.query contains the text after #
// Call e.detail.resolve(suggestions) to show dropdown
});
Dropdown Navigation
Keyboard shortcuts in the dropdown:
↑/↓— Navigate suggestionsEnter/Tab— Confirm selectionEscape— Close dropdownClick— Confirm with mouse
Phoenix LiveView
Setup
In your Phoenix app.js:
import { MarkdownInputHook, register } from '@duskmoon-dev/el-markdown-input';
register();
let liveSocket = new LiveSocket('/live', Socket, {
hooks: { MarkdownInput: MarkdownInputHook }
});
Template
<.form :let={f} for={@changeset} phx-change="validate" phx-submit="save">
<el-dm-markdown-input
id="content-input"
name="post[content]"
data-value={@post.content}
phx-hook="MarkdownInput"
/>
<button type="submit">Save Post</button>
</.form>
Handler
def handle_event("content_changed", %{"value" => value}, socket) do
# Handle content changes from the editor
{:noreply, assign(socket, content: value)}
end
def handle_event("upload_file", %{"name" => filename}, socket) do
# Optional: handle server-side file uploads
{:noreply, socket}
end
CSS Custom Properties
Customize the editor appearance with CSS custom properties:
el-dm-markdown-input {
--md-border: #d0d7de;
--md-border-focus: #0969da;
--md-bg: #ffffff;
--md-bg-toolbar: #f6f8fa;
--md-bg-hover: #eaeef2;
--md-text: #1f2328;
--md-text-muted: #656d76;
--md-accent: #0969da;
--md-radius: 6px;
--md-upload-bar: #0969da;
}
/* Dark mode */
el-dm-markdown-input[dark] {
--md-border: #30363d;
--md-border-focus: #58a6ff;
--md-bg: #0d1117;
--md-bg-toolbar: #161b22;
--md-bg-hover: #262c36;
--md-text: #c9d1d9;
--md-text-muted: #8b949e;
--md-accent: #58a6ff;
}
| Property | Default (Light) | Default (Dark) | Purpose |
|---|---|---|---|
--md-border | #d0d7de | #30363d | Editor border color |
--md-border-focus | #0969da | #58a6ff | Focus ring color |
--md-bg | #ffffff | #0d1117 | Editor background |
--md-bg-toolbar | #f6f8fa | #161b22 | Toolbar background |
--md-bg-hover | #eaeef2 | #262c36 | Hover state background |
--md-text | #1f2328 | #c9d1d9 | Primary text color |
--md-text-muted | #656d76 | #8b949e | Muted text color |
--md-accent | #0969da | #58a6ff | Active tab color |
--md-radius | 6px | 6px | Border radius |
--md-upload-bar | #0969da | #58a6ff | Upload progress bar color |
Dark Mode
Enable dark mode by adding the dark attribute:
<el-dm-markdown-input dark></el-dm-markdown-input>
Or use the [dark] CSS selector:
el-dm-markdown-input[dark] {
/* Dark mode overrides */
}
The component uses DuskMoon Moonlight theme colors by default in dark mode.
Resizable Editor
Use the resize attribute to let users drag the editor to their preferred height:
Resizable Editor
<!-- vertical resize only (recommended) -->
<el-dm-markdown-input resize="vertical"></el-dm-markdown-input>
<!-- resize in both axes -->
<el-dm-markdown-input resize="both"></el-dm-markdown-input>
Accepted values mirror the CSS resize property: none (default) | vertical | horizontal | both.
Write-Only Mode
Use the no-preview attribute to hide the toolbar and preview tab entirely. This is useful when the preview is handled externally or not needed:
Write-Only Mode
<el-dm-markdown-input no-preview></el-dm-markdown-input>
When no-preview is set, the Ctrl+Shift+P toggle shortcut is also disabled.
Bottom Toolbar Slots
The status bar supports three named slots for custom toolbar content. Existing functionality (attach button, word count) is shown by default when no slot content is provided.
Custom Left Side (bottom-start)
Replace only the left side of the toolbar:
Custom Left Side
Replace the attach button with custom actions
<el-dm-markdown-input placeholder="Left side has custom buttons…">
<div slot="bottom-start" style="display: flex; gap: 0.25rem;">
<button type="button">+ New</button>
<button type="button">🗑 Clear</button>
</div>
</el-dm-markdown-input>
Custom Right Side (bottom-end)
Replace only the right side (word count area):
Custom Right Side
Replace the word count with a submit button
<el-dm-markdown-input placeholder="Right side has a submit button…">
<div slot="bottom-end" style="display: flex; gap: 0.5rem; align-items: center;">
<span>Draft</span>
<button type="button">Send</button>
</div>
</el-dm-markdown-input>
Entire Bottom Bar (bottom)
Replace the entire toolbar with custom content:
Full Custom Toolbar
Replace the entire bottom bar
<el-dm-markdown-input placeholder="Entire bottom bar is custom…">
<div slot="bottom" style="display: flex; justify-content: space-between; width: 100%;">
<div>
<button type="button">+</button>
<button type="button">🗑</button>
</div>
<div>
<select>
<option>Opus 4.6</option>
<option>Sonnet 4.6</option>
</select>
<button type="button">▲ Send</button>
</div>
</div>
</el-dm-markdown-input>
Slot Reference
| Slot Name | Replaces | Fallback Content |
|---|---|---|
bottom | Entire toolbar | Left/right sections with attach button + word count |
bottom-start | Left side only | Attach files button |
bottom-end | Right side only | Word/character count |
Note: When
slot="bottom"is used, it replaces the entire bar including the innerbottom-startandbottom-endslots. The hidden file<input>for uploads always remains functional regardless of slot usage.
Keyboard Shortcuts
| Shortcut | Action |
|---|---|
Ctrl+Shift+P / Cmd+Shift+P | Toggle Preview tab |
Tab | Indent current line / selection by 2 spaces |
Shift+Tab | De-indent current line / selection |
↑ / ↓ (in dropdown) | Navigate autocomplete |
Enter / Tab (in dropdown) | Confirm autocomplete |
Escape (in dropdown) | Close dropdown |
Accessibility
- Full keyboard navigation throughout the editor
- ARIA labels for tab buttons and controls
- Semantic HTML structure in shadow DOM
- Clear focus indicators on all interactive elements
- Form field labeling support via
nameattribute - Screen reader announcements for upload progress
- Autocomplete dropdown is keyboard accessible
Preview Tab (Render Pipeline)
The preview tab transforms raw markdown into styled HTML using a unified pipeline:
- GFM — Tables, task lists, strikethrough, autolinks via remark-gfm
- Math — Inline
$E=mc^2$and display$$...$$math via KaTeX - Code highlighting — Syntax colors for fenced code blocks via rehype-prism-plus
- Mermaid — Diagrams rendered as SVG (lazy-loaded post-render step)
- XSS protection — All output sanitized via rehype-sanitize
The pipeline is lazy-loaded — dependencies are only imported on first preview tab activation. Subsequent tab switches reuse the cached processor.
Live Preview
Enable auto-updating preview while typing:
<el-dm-markdown-input live-preview debounce="300"></el-dm-markdown-input>
Self-hosting KaTeX and Mermaid
By default, KaTeX CSS loads from jsDelivr CDN. Override for self-hosted setups:
<el-dm-markdown-input
katex-css-url="/assets/katex.min.css"
mermaid-src="/assets/mermaid.esm.min.mjs"
></el-dm-markdown-input>
Integration with @duskmoon-dev/core
The preview tab uses the markdown-body styles from @duskmoon-dev/core/components/markdown-body, so the rendered markdown automatically respects your theme configuration.
Browser Support
| Browser | Version |
|---|---|
| Chrome/Edge | 111+ |
| Firefox | 113+ |
| Safari | 16.4+ |
Required APIs:
- Custom Elements v1
- Shadow DOM v1
- ElementInternals
- Fetch API
- FormData API