Writemark: Live Inline Markdown Editor with Custom Actions and Events

Category: Javascript , Text | July 31, 2026
AuthorBrostoffed
Last UpdateJuly 31, 2026
LicenseMIT
Views21 views
Writemark: Live Inline Markdown Editor with Custom Actions and Events

Writemark is a dependency-free Markdown editor Web Component that renders formatted content directly inside its editable surface.

Headings, lists, task checkboxes, tables, inline marks, and fenced code blocks appear in their rendered form while raw Markdown remains the component’s canonical value.

The editor registers as <writemark-editor> and works with classic scripts, ES modules, npm-based projects, and native HTML forms.

Features:

  • Live inline rendering for supported Markdown structures.
  • Canonical raw Markdown for application state and form submission.
  • Live, source, split, and read-only preview modes.
  • Slash commands, Markdown shortcuts, and keyboard formatting controls.
  • Editable task lists, tables, code fences, blockquotes, and horizontal rules.
  • Source-backed selection, undo, redo, find, replace, and clipboard handling.
  • Native form association, validation, reset, disabled, and read-only states.
  • Public actions and completion providers for host-defined editing workflows.
  • Host-controlled paste and drop events for file upload integration.
  • CSS custom properties and Parts for Shadow DOM styling.
  • Sanitized built-in HTML rendering with blocked unsafe URL schemes.
  • Zero runtime dependencies and no default network requests.

How To Use It:

Installation

Load the core JavaScript in your plain HTML page.

<script src="./dist/writemark-editor.global.js"></script>

The script registers <writemark-editor> and exposes helper exports through globalThis.WritemarkEditor.

A page served over HTTP can load the ES module build directly.

<script type="module" src="/dist/writemark-editor.js"></script>

You can also install and import the package via NPM.

npm install writemark-editor
import 'writemark-editor';

Basic Usage

Add the browser build, place the custom element in the document, and set multiline Markdown through the value property.

<script src="./dist/writemark-editor.global.js"></script>
<writemark-editor
  id="release-notes"
  name="notes"
  label="Release notes"
  placeholder="Type / to insert a block"
></writemark-editor>
<script>
  customElements.whenDefined('writemark-editor').then(() => {
    const editor = document.querySelector('#release-notes');
    editor.value = [
      '# Version 2.4',
      '',
      '- Added keyboard navigation',
      '- Fixed table editing'
    ].join('\n');
    editor.addEventListener('md-input', (event) => {
      console.log(event.detail.value);
    });
  });
</script>

editor.value and editor.getMarkdown() return raw Markdown. editor.getHTML() returns rendered HTML from the built-in sanitizer, while editor.getText() and editor.getPlainText() return structural plain text.

const editor = document.querySelector('#release-notes');
console.log(editor.value);
console.log(editor.getMarkdown());
console.log(editor.getHTML());
console.log(editor.getText());

Choose an Editing Mode

The mode attribute selects the primary editing or viewing surface.

<writemark-editor mode="live"></writemark-editor>
<writemark-editor mode="source"></writemark-editor>
<writemark-editor mode="split"></writemark-editor>
<writemark-editor mode="preview"></writemark-editor>
ModeBehavior
liveRenders supported Markdown structures inside the editable surface.
sourceShows the complete raw Markdown in a native textarea.
splitPlaces the source textarea beside a rendered preview on wider screens.
previewShows a focusable, read-only rendered view.

An editing mode can also display an extra preview through preview="below", preview="side", or preview="inline-split".

<writemark-editor
  mode="live"
  preview="below"
  label="Project summary"
></writemark-editor>

Live mode is experimental on mobile browsers. Set mode="source" for production editing on iOS, iPadOS, Android, and other software-keyboard environments.

Submit Raw Markdown in a Form

Writemark is a form-associated custom element. The browser submits its raw Markdown under the name attribute.

<form id="story-form">
  <writemark-editor
    name="story"
    label="Story"
    mode="live"
    required
    minlength="80"
    maxlength="12000"
  ></writemark-editor>
  <button type="submit">Save story</button>
  <button type="reset">Reset</button>
</form>
<script>
  const form = document.querySelector('#story-form');
  form.addEventListener('submit', (event) => {
    event.preventDefault();
    const data = new FormData(form);
    console.log(data.get('story'));
  });
</script>

The component supports required, minlength, maxlength, disabled, native form reset, disabled fieldsets, checkValidity(), reportValidity(), and setCustomValidity().

defaultValue controls the reset target. Call commit() after loading or saving a document when the current Markdown should become the clean value and the next reset target.

const editor = document.querySelector('writemark-editor');
editor.value = loadedMarkdown;
editor.commit();
console.log(editor.dirty); // false

Add a Host Toolbar

Writemark does not render formatting controls. A host toolbar can call built-in actions through exec() and reflect the current selection through getActiveMarks().

<div id="editor-toolbar" role="toolbar" aria-label="Text formatting">
  <button type="button" data-action="inline.bold" aria-label="Bold">
    Bold
  </button>
  <button type="button" data-action="inline.italic" aria-label="Italic">
    Italic
  </button>
  <button type="button" data-action="block.heading.2">
    Heading 2
  </button>
  <button type="button" data-action="block.table">
    Table
  </button>
</div>
<writemark-editor
  id="article-editor"
  label="Article body"
></writemark-editor>
const editor = document.querySelector('#article-editor');
const toolbar = document.querySelector('#editor-toolbar');
toolbar.addEventListener('click', (event) => {
  const button = event.target.closest('[data-action]');
  if (!button) return;
  const actionId = button.dataset.action;
  const args = actionId === 'block.table'
    ? { rows: 2, cols: 3 }
- undefined;
  editor.exec(actionId, args);
  editor.focus();
});
function updateToolbar() {
  const activeMarks = new Set(editor.getActiveMarks());
  for (const button of toolbar.querySelectorAll('[data-action]')) {
    const actionId = button.dataset.action;
    button.disabled = !editor.canExec(actionId);
    if (
      actionId.startsWith('inline.') ||
      actionId.startsWith('block.heading.')
    ) {
      button.setAttribute(
        'aria-pressed',
        String(activeMarks.has(actionId))
      );
    }
  }
}
editor.addEventListener('md-selection-change', updateToolbar);
editor.addEventListener('md-input', updateToolbar);
updateToolbar();

Formatting actions such as bold and italic work as toggle buttons. Insert actions such as table creation should remain ordinary buttons.

Insert Structured Markdown

The action API handles structural edits and keeps selection, undo, dirty state, and change events synchronized.

const editor = document.querySelector('#article-editor');
// Insert a three-column table with two body rows.
editor.exec('block.table', {
  rows: 2,
  cols: 3
});
// Insert a fenced TypeScript block.
editor.exec('block.codeFence', {
  language: 'typescript'
});
// Apply a link to selected text.
editor.exec('inline.link', {
  url: 'https://example.com/docs'
});
// Insert an image at the current source selection.
editor.exec('inline.image', {
  alt: 'Editor workflow',
  src: '/assets/editor-workflow.png'
});

Table row counts clamp from 1 to 20. Column counts clamp from 2 to 12. Table rows and columns can be changed later through table actions.

editor.exec('table.insertRowAfter');
editor.exec('table.insertColumnAfter');
editor.exec('table.deleteRow');
editor.exec('table.deleteColumn');

Save Drafts and Commit Revisions

md-input reports each canonical value change. md-change marks an explicit commit boundary.

const editor = document.querySelector('#article-editor');
let draftTimer;
editor.addEventListener('md-input', (event) => {
  clearTimeout(draftTimer);
  draftTimer = setTimeout(() => {
    localStorage.setItem('article-draft', event.detail.value);
  }, 500);
});
editor.addEventListener('md-change', (event) => {
  saveRevision(event.detail.value);
});
document.querySelector('#save-revision').addEventListener('click', () => {
  editor.commit();
});

Avoid assigning editor.value after every md-input event. The component already owns the active selection and undo transaction. Write a new value only when external state has changed.

Handle File Paste and Drop

The editor emits file events and leaves upload policy to the host application. Each event includes the files, the captured source insertion point, and an insertMarkdown callback.

async function uploadEditorFile(event) {
  const { files, insertionPoint, insertMarkdown } = event.detail;
  const file = files[0];
  if (!file) return;
  const result = await uploadAsset(file);
  editor.setSelectionRange(insertionPoint, insertionPoint);
  insertMarkdown(`![${file.name}](${result.url})`);
}
editor.addEventListener('md-file-paste', uploadEditorFile);
editor.addEventListener('md-file-drop', uploadEditorFile);

Validate file type, size, permissions, filename, and returned URL in the host application. Long uploads also need a policy for stale insertion points when the document changes before the request completes.

Add a Custom Completion Provider

Completion providers can add mentions, templates, references, or remote search results to the editor. A provider defines how to detect a query, load items, and convert the accepted item into a source transaction.

const teamMembers = [
  { id: 'maya', label: 'Maya Chen' },
  { id: 'sam', label: 'Sam Rivera' }
];
editor.registerCompletionProvider({
  id: 'team-member',
  priority: 40,
  triggers: ['@'],
  match(context) {
    const caretOffset =
      context.selectionStart - context.currentLine.start;
    const textBeforeCaret =
      context.currentLine.text.slice(0, caretOffset);
    const match = /@([\w-]*)$/.exec(textBeforeCaret);
    if (
      !match ||
      context.selectionStart !== context.selectionEnd
    ) {
      return null;
    }
    return {
      from: context.selectionStart - match[0].length,
      to: context.selectionStart,
      query: match[1]
    };
  },
  getItems(match, context, signal) {
    if (signal.aborted) return [];
    const query = match.query.toLowerCase();
    return teamMembers
      .filter((member) =>
        member.label.toLowerCase().includes(query)
      )
      .map((member) => ({
        id: member.id,
        label: member.label,
        detail: 'team member',
        kind: 'mention'
      }));
  },
  apply(item, match, context) {
    const markdown =
      `[${item.label}](/team/${item.id})`;
    const caret = match.from + markdown.length;
    return {
      ok: true,
      transaction: {
        changes: [{
          from: match.from,
          to: match.to,
          insert: markdown
        }],
        selectionBefore: {
          start: context.selectionStart,
          end: context.selectionEnd,
          direction: context.selectionDirection
        },
        selectionAfter: {
          start: caret,
          end: caret,
          direction: 'none'
        },
        undoGroup: 'team-member-completion'
      }
    };
  }
});

Use the supplied AbortSignal for remote requests. Writemark cancels stale completion work when the query or active provider changes.

Attributes and Properties

Content and View

  • value (string): Sets the initial/reset Markdown as an attribute and the current canonical Markdown as a property.
  • label (string): Adds a visible label and an accessible naming source.
  • placeholder (string): Displays empty-state text. The default is Write markdown....
  • mode (live | source | split | preview): Selects the active editing or viewing surface. The default is live.
  • preview (none | below | side | inline-split): Selects an optional preview placement. The default is none.
  • markdown-flavor (gfm | commonmark): Selects the supported Markdown feature profile. The default is gfm.
  • render-debounce-ms (0 to 1000): Sets preview render debounce in milliseconds. The default is 100.

Editing Behavior

  • tab-behavior (accessibility-first | editor-first): Controls ordinary Tab behavior outside structural contexts. The default is accessibility-first.
  • indent-string (tab | 2 | 2-spaces | 4 | 4-spaces): Controls indentation used by list and editor actions. The default is two spaces.
  • spellcheck (true | false): Controls spellcheck on prose surfaces. Fenced code always disables spellcheck.
  • readonly (boolean): Keeps content focusable and selectable while blocking edits.
  • disabled (boolean): Blocks interaction and removes the field from form submission.
  • dir (ltr | rtl | auto): Propagates text direction to active editing surfaces.

Forms and Validation

  • name (string): Sets the submitted form field name.
  • required (boolean): Rejects empty and whitespace-only Markdown.
  • minlength (number): Sets the minimum length for nonempty Markdown.
  • maxlength (number): Sets the maximum Markdown length.
  • aria-label (string): Supplies an accessible name to the active editor surface.
  • aria-labelledby (string): References one or more naming elements.

Diagnostics

  • debug (number): Selects the diagnostic level. Level 0 disables diagnostics, level 1 reports input decisions, and level 2 adds selection and focus data.
  • debug-log (boolean): Mirrors enabled diagnostics to console.debug.

API Methods

const editor = document.querySelector('writemark-editor');
// Focus the active live, source, or preview surface.
editor.focus();
// Blur the active internal surface.
editor.blur();
// Select the complete Markdown value.
editor.select();
// Set the canonical source selection.
editor.setSelectionRange(0, 12, 'forward');
// Return Markdown in the current source range.
editor.getSelectionMarkdown();
// Return canonical Markdown.
editor.getMarkdown();
// Set canonical Markdown.
editor.setMarkdown('# Updated document');
// Insert Markdown at the current selection.
editor.insertMarkdown('\n\nNew paragraph');
// Return sanitized rendered HTML.
editor.getHTML();
// Return structural plain text.
editor.getText();
// Alias of getText().
editor.getPlainText();
// Execute a built-in or custom action.
editor.exec('inline.bold');
// Check whether an action is currently available.
editor.canExec('inline.bold');
// Register or replace a custom action.
editor.registerAction(customAction);
// Remove a custom action.
editor.unregisterAction('insert.notice');
// Return the parsed block at the caret.
editor.getCurrentBlock();
// Return parsed blocks that intersect the selection.
editor.getSelectedBlocks();
// Return active block and inline action IDs.
editor.getActiveMarks();
// Register or replace a completion provider.
editor.registerCompletionProvider(completionProvider);
// Remove a completion provider.
editor.unregisterCompletionProvider('team-member');
// Find and select the next source match.
editor.find('draft', {
  caseSensitive: false,
  from: editor.selectionEnd,
  wrap: true
});
// Replace the matching selection or next match.
editor.replace('draft', 'final', {
  caseSensitive: false,
  wrap: true
});
// Replace every nonoverlapping source match.
editor.replaceAll('TODO', 'Done', {
  caseSensitive: true
});
// Mark the current value clean and set the reset target.
editor.commit();
// Restore defaultValue and clear dirty state.
editor.reset();
// Run native constraint validation.
editor.checkValidity();
// Run validation and expose the current message.
editor.reportValidity();
// Set or clear a custom validation error.
editor.setCustomValidity('Add at least one heading.');

Built-in Actions

// Editor and history.
editor.exec('editor.insertText', { text: 'New text' });
editor.exec('editor.replaceSelection', {
  text: 'Replacement'
});
editor.exec('editor.insertParagraph');
editor.exec('editor.insertSoftBreak');
editor.exec('editor.smartEnter');
editor.exec('editor.smartTab');
editor.exec('editor.smartOutdent');
editor.exec('editor.smartBackspace');
editor.exec('editor.smartDelete');
editor.exec('editor.markdownShortcut');
editor.exec('editor.deleteSelection');
editor.exec('editor.selectAllExpand');
editor.exec('history.undo');
editor.exec('history.redo');
// Blocks and insertion.
editor.exec('block.paragraph');
editor.exec('block.heading.1');
editor.exec('block.heading.2');
editor.exec('block.heading.3');
editor.exec('block.heading.4');
editor.exec('block.heading.5');
editor.exec('block.heading.6');
editor.exec('block.bulletList');
editor.exec('block.orderedList');
editor.exec('block.taskList');
editor.exec('block.taskDone');
editor.exec('block.blockquote');
editor.exec('block.codeFence', { language: 'css' });
editor.exec('block.horizontalRule');
editor.exec('block.table', { rows: 2, cols: 4 });
// Tables and code.
editor.exec('table.insertRowAfter');
editor.exec('table.insertColumnAfter');
editor.exec('table.deleteRow');
editor.exec('table.deleteColumn');
editor.exec('code.setLanguage', {
  language: 'javascript'
});
// Inline formatting.
editor.exec('inline.bold');
editor.exec('inline.italic');
editor.exec('inline.code');
editor.exec('inline.strikethrough');
editor.exec('inline.link', { url: '/guide' });
editor.exec('inline.image', {
  alt: 'Guide cover',
  src: '/images/guide-cover.png'
});
// Views and completion.
editor.exec('view.live');
editor.exec('view.source');
editor.exec('completion.close');
editor.exec('completion.next');
editor.exec('completion.previous');
editor.exec('completion.first');
editor.exec('completion.last');
editor.exec('completion.accept');

Module Exports

The ES module exposes the custom-element classes, renderer helpers, source parsers, and clipboard conversion utilities.

import {
  WritemarkEditorElement,
  MdLiveEditorElement,
  renderMarkdown,
  renderInlineMarkdown,
  parseBlocks,
  parseListItem,
  parseHeading,
  parseBlockquote,
  htmlToMarkdown,
  tsvToMarkdownTable
} from 'writemark-editor';

Events

All public events bubble and cross the Shadow DOM boundary. md-before-change is the only cancelable event.

// Fires before a canonical source transaction.
// Call preventDefault() to block the change.
editor.addEventListener('md-before-change', (event) => {
  console.log(event.detail.nextValue);
});
// Fires after the canonical Markdown changes.
editor.addEventListener('md-input', (event) => {
  console.log(event.detail.value);
});
// Fires after commit() or a source textarea change boundary.
editor.addEventListener('md-change', (event) => {
  console.log(event.detail.value);
});
// Fires when the source-backed selection changes.
editor.addEventListener('md-selection-change', (event) => {
  console.log(event.detail.selectionStart);
  console.log(event.detail.selectionEnd);
});
// Fires after an action runs.
editor.addEventListener('md-action', (event) => {
  console.log(event.detail.actionId);
});
// Fires when a completion popup opens.
editor.addEventListener('md-completion-open', (event) => {
  console.log(event.detail.providerId);
});
// Fires when a completion popup closes.
editor.addEventListener('md-completion-close', (event) => {
  console.log(event.detail.providerId);
});
// Fires after a completion item is accepted.
editor.addEventListener('md-completion-accept', (event) => {
  console.log(event.detail.item);
});
// Fires after rendered preview HTML is generated.
editor.addEventListener('md-render', (event) => {
  console.log(event.detail.html);
});
// Fires when a file is pasted.
editor.addEventListener('md-file-paste', (event) => {
  console.log(event.detail.files);
});
// Fires when a file is dropped.
editor.addEventListener('md-file-drop', (event) => {
  console.log(event.detail.files);
});
// Fires after live mode copies Markdown-backed content.
editor.addEventListener('md-copy', (event) => {
  console.log(event.detail.markdown);
});
// Fires after live mode cuts Markdown-backed content.
editor.addEventListener('md-cut', (event) => {
  console.log(event.detail.markdown);
});
// Fires after clipboard content enters the canonical source.
editor.addEventListener('md-paste', (event) => {
  console.log(event.detail.kind);
});
// Fires when dirty state changes.
editor.addEventListener('md-dirty-change', (event) => {
  console.log(event.detail.dirty);
});
// Fires when the selected diagnostic level emits a record.
editor.addEventListener('md-debug', (event) => {
  console.log(event.detail);
});
// Fires after a recoverable editor error.
editor.addEventListener('md-error', (event) => {
  console.error(
    event.detail.phase,
    event.detail.error
  );
});

Styling and Customization

Writemark uses an open Shadow DOM. Regular descendant selectors do not reach internal controls. Set custom properties on the host and use ::part() for stable internal targets.

writemark-editor.article-field {
  --md-editor-font: Inter, system-ui, sans-serif;
  --md-editor-mono-font: "JetBrains Mono", monospace;
  --md-editor-font-size: 15px;
  --md-editor-line-height: 1.65;
  --md-editor-min-height: 360px;
  --md-editor-max-height: 70vh;
  --md-editor-radius: 8px;
  --md-editor-border-focus: #2563eb;
}
writemark-editor.article-field::part(code-header) {
  font-weight: 700;
  letter-spacing: 0.03em;
}
writemark-editor.article-field::part(preview) {
  padding: 20px;
}

CSS Custom Properties

  • --md-editor-font: Sets the prose font stack.
  • --md-editor-mono-font: Sets the code and source font stack.
  • --md-editor-font-size: Sets the base editor font size. The default is 15px.
  • --md-editor-line-height: Sets the editor line height. The default is 1.55.
  • --md-editor-bg: Sets the editing surface background.
  • --md-editor-fg: Sets the primary foreground color.
  • --md-editor-muted: Sets muted labels and secondary text.
  • --md-editor-token: Sets visible Markdown token color.
  • --md-editor-border: Sets the default border color.
  • --md-editor-border-focus: Sets the focused border color.
  • --md-editor-radius: Sets the shared border radius. The default is 10px.
  • --md-editor-padding: Sets surface padding. The default is 14px.
  • --md-editor-min-height: Sets the minimum editor height. The default is 220px.
  • --md-editor-max-height: Sets the maximum editor height. The default is none.
  • --md-editor-focus-ring: Sets the outer focus ring.
  • --md-editor-active-line-ring: Sets the focused live-block ring. The default is none.
  • --md-editor-active-line-bg: Sets the focused live-block background. The default is transparent.
  • --md-editor-active-cell-ring: Sets the focused table-cell ring.
  • --md-editor-active-cell-bg: Sets the focused table-cell background.
  • --md-editor-popup-bg: Sets the completion popup background.
  • --md-editor-popup-fg: Sets the completion popup foreground.
  • --md-editor-popup-border: Sets the completion popup border.
  • --md-editor-popup-shadow: Sets the completion popup shadow.
  • --md-editor-preview-bg: Sets the preview background.
  • --md-editor-preview-fg: Sets the preview foreground.
  • --md-editor-code-bg: Sets code block and inline code backgrounds.
  • --md-editor-code-header-bg: Sets fenced code header backgrounds.
  • --md-editor-code-accent: Sets fenced code header accents.
  • --md-editor-danger: Sets validation error color. The default is #b00020.
  • --md-editor-transition-duration: Sets transition duration. The default is 140ms.
  • --md-editor-transition-ease: Sets transition easing.

CSS Parts

  • container: Targets the complete labeled component layout.
  • label: Targets the visible editor label.
  • editor: Targets the editor shell.
  • live-editor: Targets the live editing surface.
  • textarea: Targets the source textarea.
  • preview: Targets the rendered preview.
  • completion-popup: Targets the completion listbox.
  • completion-item: Targets every completion option.
  • completion-item-active: Targets the active completion option.
  • line: Targets editable rendered lines and terminal anchors.
  • checkbox: Targets task checkboxes.
  • code-block: Targets complete fenced code blocks.
  • code-header: Targets code-language headers.
  • code-lines: Targets code-line containers.
  • code-line: Targets individual editable code lines.
  • table: Targets rendered tables.
  • table-cell: Targets editable table cells.
  • error: Targets the validation message region.
  • status: Targets the screen-reader status region.

Split and side-preview layouts use two columns above 720px and stack below that width. Reduced-motion preferences reduce editor transition and animation durations.

Keyboard and Clipboard Behavior

Typing / at the start of a line opens the built-in command menu. Markdown block shortcuts recognize headings, lists, task items, blockquotes, and related structures when their marker is followed by Space.

ShortcutAction
Mod+BToggle bold.
Mod+IToggle italic.
Mod+EToggle inline code.
Mod+KInsert or edit a link.
Mod+Shift+XToggle strikethrough.
Mod+Alt+1 through Mod+Alt+6Toggle heading levels.

Alternatives:

FAQs:

Q: Does Writemark require a JavaScript framework?
A: No. It is a native custom element with no runtime dependencies. It also works inside framework applications that can render and access a DOM element.

Q: Why does the editor have no formatting toolbar?
A: Formatting comes from Markdown shortcuts, slash commands, keyboard shortcuts, and public actions. The host application can build a toolbar that matches its own product UI.

Q: What value should the application save?
A: Save editor.value or editor.getMarkdown(). Do not serialize content from the Shadow DOM because the rendered live surface can hide canonical Markdown syntax.

Q: How do I get the submitted form value?
A: The component submits raw Markdown as the form value. Use editor.value or editor.getMarkdown() to read it programmatically.

Q: Does Writemark handle file uploads?
A: No. The component emits md-file-paste and md-file-drop events for files, but the host application is responsible for uploading and inserting Markdown links or images.

You Might Be Interested In:


Leave a Reply