v1.10.2
Theme

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 — @mention and #reference with 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
auto-grow boolean false Grow the write area with content up to its maximum height
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-data with field name file
  • 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 value
  • content_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
});

Keyboard shortcuts in the dropdown:

  • ↑ / ↓ — Navigate suggestions
  • Enter / Tab — Confirm selection
  • Escape — Close dropdown
  • Click — 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-editor-min-height: 12rem;
  --md-editor-max-height: 24rem;
  --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-editor-min-height 12rem 12rem Minimum editor height, including toolbar and status bar
--md-editor-max-height 24rem 24rem Maximum editor height when auto-grow is enabled
--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.

Editor Height and Auto-Growth

The editor keeps its existing 12rem minimum height by default. Set --md-editor-min-height on the element to make it more compact. Add auto-grow to expand the write area as text is entered; the write area scrolls after the editor reaches --md-editor-max-height. Wrapped lines also contribute to growth. Both properties include the toolbar and status bar in the editor’s total height.

<el-dm-markdown-input
  no-preview
  auto-grow
  style="--md-editor-min-height: 4.5rem; --md-editor-max-height: 18rem;"
></el-dm-markdown-input>

The JavaScript property is editor.autoGrow = true. Remove the attribute or set the property to false to restore the regular editor sizing behavior.

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

Draft
<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 inner bottom-start and bottom-end slots. 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 name attribute
  • 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
  • Front matter — Leading YAML renders as a highlighted code block
  • Color chips — Inline HEX, RGB, and HSL values render with color previews
  • Soft line breaks — Single newlines render as line breaks by default
  • 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 and the browser’s Mermaid bundle load from jsDelivr. Mermaid loads only when a preview contains a diagram, using this package’s fixed version. The bundle includes patched KaTeX and does not require a separate Mermaid install.

To self-host, copy katex/dist/ (including its fonts) and this package’s dist/esm/mermaid.js to your HTTPS server, then provide their URLs:

<el-dm-markdown-input
  katex-css-url="https://example.com/assets/katex/katex.min.css"
  mermaid-src="https://example.com/assets/mermaid.js"
></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