v1.2.0
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

PropertyTypeDefaultDescription
namestring''Form field name for submission
valuestring''Current markdown content
placeholderstring'Write markdown…'Textarea placeholder text
disabledbooleanfalseDisables editing
readonlybooleanfalseMakes the editor read-only (value still submitted with forms)
upload-urlstringundefinedPOST endpoint for file uploads
max-wordsnumberundefinedOptional soft word count cap
darkbooleanfalseUse dark theme variant
live-previewbooleanfalseAuto-update preview while typing
debouncenumber300Debounce ms for live preview
katex-css-urlstringCDN URLOverride KaTeX stylesheet location
mermaid-srcstringCDN URLOverride mermaid.js source URL
no-previewbooleanfalseHide 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.

EventDetailWhen
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:

Modeupload-urlBehavior
Remote uploadSetFiles are uploaded via XHR and a markdown link is inserted
Local attachmentNot setFiles 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-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;
}
PropertyDefault (Light)Default (Dark)Purpose
--md-border#d0d7de#30363dEditor border color
--md-border-focus#0969da#58a6ffFocus ring color
--md-bg#ffffff#0d1117Editor background
--md-bg-toolbar#f6f8fa#161b22Toolbar background
--md-bg-hover#eaeef2#262c36Hover state background
--md-text#1f2328#c9d1d9Primary text color
--md-text-muted#656d76#8b949eMuted text color
--md-accent#0969da#58a6ffActive tab color
--md-radius6px6pxBorder radius
--md-upload-bar#0969da#58a6ffUpload 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

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 NameReplacesFallback Content
bottomEntire toolbarLeft/right sections with attach button + word count
bottom-startLeft side onlyAttach files button
bottom-endRight side onlyWord/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

ShortcutAction
Ctrl+Shift+P / Cmd+Shift+PToggle Preview tab
TabIndent current line / selection by 2 spaces
Shift+TabDe-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
  • 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

BrowserVersion
Chrome/Edge111+
Firefox113+
Safari16.4+

Required APIs:

  • Custom Elements v1
  • Shadow DOM v1
  • ElementInternals
  • Fetch API
  • FormData API