Skip to content

Markdown Editor Preview

WYSIWYG Markdown editor for Vue 3 based on Milkdown (Kit approach), styled with the Cocoar Design System. Markdown-first: lossless round-trip between text and editor state. Shares the same remark stack — and the same render registry — as @cocoar/vue-markdown-core and <CoarMarkdown>.

Separate Package

bash
pnpm add @cocoar/vue-markdown-editor @cocoar/vue-markdown @cocoar/vue-ui

@cocoar/vue-markdown, @cocoar/vue-ui and vue are peer dependencies. Milkdown is bundled as a regular dependency — no extra setup required. The peer-dep on @cocoar/vue-markdown is what makes the shared rendering registry work — code blocks, tables, etc. look identical here and in <CoarMarkdown>.

Import the stylesheets once at your app's entry — same pattern as @cocoar/vue-ui:

css
/* app/main.css */
@import "@cocoar/vue-ui/styles";
@import "@cocoar/vue-markdown/styles";        /* ← shared block styles */
@import "@cocoar/vue-markdown-editor/styles"; /* ← editor-specific chrome */

Preview release

The package is on the 0.0.x line. The render layer, v-model contract, toolbar API, form-field integration, and code-block view/edit toggle are stable enough to ship in internal Cocoar apps — the source format is plain Markdown, so any content written today round-trips through future API changes.

Still missing for a 1.0: link insertion dialog, image upload, placeholder text, and custom table edge-handles. See TODO below.

Basic Usage

The editor exposes a plain v-model for the markdown string and renders a floating toolbar on text selection.

vue
<template>
  <ClientOnly>
    <div class="md-frame">
      <component :is="Editor" v-if="Editor" v-model="value" />
      <div v-else class="md-frame__loading">Loading editor…</div>
    </div>
    <details class="md-output">
      <summary>Raw markdown (v-model)</summary>
      <pre>{{ value }}</pre>
    </details>
  </ClientOnly>
</template>

<script setup lang="ts">
import { onMounted, ref, shallowRef, type Component } from 'vue';

const value = ref(`# Try me

Select some text to see the **floating toolbar**, or insert a:

| Column A | Column B |
|----------|----------|
| Edit me  | Edit me  |

> Blockquote works too.
`);

const Editor = shallowRef<Component | null>(null);

onMounted(async () => {
  const mod = await import('@cocoar/vue-markdown-editor');
  Editor.value = mod.CoarMarkdownEditor;
});
</script>

<style scoped>
.md-frame {
  height: 360px;
  border: 1px solid var(--coar-border-neutral);
  border-radius: var(--coar-radius-xl);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.md-frame__loading {
  padding: 24px;
  text-align: center;
  color: var(--coar-text-neutral-tertiary);
  font-size: 13px;
}

.md-output { margin-top: 12px; }
.md-output summary {
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
}
.md-output pre {
  margin-top: 8px;
  padding: 12px;
  background: var(--coar-background-neutral-secondary);
  border-radius: var(--coar-radius-xl);
  font-size: 12px;
  max-height: 200px;
  overflow: auto;
  white-space: pre-wrap;
}
</style>
vue
<template>
  <CoarMarkdownEditor v-model="value" />
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { CoarMarkdownEditor } from '@cocoar/vue-markdown-editor';

const value = ref('# Hello\n\nStart typing **markdown**.');
</script>

Sizing

The editor fills its parent container. Wrap it in a parent with explicit height (height: 360px, flex: 1 inside a column flexbox, etc.).

Toolbar Modes

toolbarMode controls the layout. Three values:

ValueDescription
'floating' (default)Appears on text selection, teleported to <body>, context-aware (text vs. table)
'fixed'CoarSidebar collapsed with icon buttons and flyout submenus, persistent. Sits on any of the four edges — see toolbarPosition
'both'Both active simultaneously

When toolbarMode is 'fixed' or 'both', toolbarPosition controls which edge the toolbar attaches to. All four edges are supported — 'left' and 'right' give a vertical icon column, 'top' and 'bottom' switch to a horizontal toolbar above or below the editor area. Flyout submenus open in the corresponding direction (right for left, downward for top, etc.).

vue
<template>
  <ClientOnly>
    <div style="display: flex; flex-direction: column; gap: 12px;">
      <CoarSelect
        v-model="position"
        :options="positionOptions"
        label="toolbar-position"
        size="s"
        style="width: 200px;"
      />
      <div class="md-frame">
        <component
          :is="Editor"
          v-if="Editor"
          v-model="value"
          toolbar-mode="fixed"
          :toolbar-position="position"
        />
        <div v-else class="md-frame__loading">Loading editor…</div>
      </div>
    </div>
  </ClientOnly>
</template>

<script setup lang="ts">
import { onMounted, ref, shallowRef, type Component } from 'vue';
import { CoarSelect } from '@cocoar/vue-ui';
import type { CoarSelectOption } from '@cocoar/vue-ui';

type Position = 'left' | 'right' | 'top' | 'bottom';

const value = ref(`# Sidebar toolbar

Use the icon strip on any of the four edges for persistent access to formatting
commands. Hover **Headings** to open the flyout.
`);

const position = ref<Position>('left');

const positionOptions: CoarSelectOption<Position>[] = [
  { value: 'left', label: 'left' },
  { value: 'right', label: 'right' },
  { value: 'top', label: 'top' },
  { value: 'bottom', label: 'bottom' },
];

const Editor = shallowRef<Component | null>(null);

onMounted(async () => {
  const mod = await import('@cocoar/vue-markdown-editor');
  Editor.value = mod.CoarMarkdownEditor;
});
</script>

<style scoped>
.md-frame {
  height: 320px;
  border: 1px solid var(--coar-border-neutral);
  border-radius: var(--coar-radius-xl);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.md-frame__loading {
  padding: 24px;
  text-align: center;
  color: var(--coar-text-neutral-tertiary);
  font-size: 13px;
}
</style>
vue
<CoarMarkdownEditor
  v-model="value"
  toolbar-mode="fixed"
  toolbar-position="top"
/>

Readonly

vue
<CoarMarkdownEditor v-model="value" readonly />

In readonly mode the editor accepts no input, the floating toolbar is suppressed, and the sidebar buttons are inert. The fixed toolbar still renders so the layout stays stable when toggling between view and edit.

Code blocks — view / edit toggle

Code blocks have a richer UX than the rest of the editor. When the cursor is outside a code block it renders as CoarCodeBlock with full Prism syntax highlighting — same component, same look as <CoarMarkdown> produces in the viewer. When the cursor moves inside the block it switches to plain editable mode plus a language selector at the top.

StateWhat rendersWhy
Cursor outsideCoarCodeBlock (Prism-highlighted, copy button, language label)Read-mode aesthetic — matches the viewer
Cursor insidePlain editable text + CoarSelect for the languageEditing on top of Prism-highlighted DOM is fragile (cursor jumps, IME issues). Plain text avoids that.

Switching directions:

  • Render → edit: hover the code block to reveal a small Edit button (top-right), or simply click into the text via PM's natural cursor placement
  • Edit → render: click anywhere outside the code block. PM's selection moves out → the NodeView swaps back automatically

Supported languages match what CoarCodeBlock ships with: typescript, javascript, json, css, scss, html, bash, plus '' (Plain text — no highlighting). The language string is persisted exactly as picked into the markdown fence (```json).

Custom code-block renderer

The render-mode component is the registry's codeBlock slot. Override it in provide(MARKDOWN_RENDERERS_KEY, { ...defaults, codeBlock: MyCustom }) and the editor's render mode picks up the same custom component without any extra wiring.

Form Integration

CoarMarkdownEditor is a full citizen of the Cocoar form ecosystem. Drop it inside CoarFormField and the label, error message, aria-describedby wiring, and disabled state propagate automatically — the same way CoarTextInput, CoarSelect, and CoarScriptEditor behave.

vue
<template>
  <ClientOnly>
    <div v-if="Editor && FormField && TextInput && Button" class="form-demo">
      <component :is="FormField" label="Title" :error="titleError" required>
        <component :is="TextInput" v-model="form.title" placeholder="My note" />
      </component>

      <component
        :is="FormField"
        label="Body"
        :error="bodyError"
        :disabled="locked"
        hint="Markdown — select text to format."
        required
      >
        <div class="md-frame">
          <component :is="Editor" v-model="form.body" />
        </div>
      </component>

      <div class="row">
        <component :is="Button" type="primary" @clicked="onSubmit">Save</component>
        <component :is="Button" @clicked="onReset">Reset</component>
        <label class="lock">
          <input v-model="locked" type="checkbox" /> lock (disabled)
        </label>
      </div>

      <pre class="preview">{{ preview }}</pre>
    </div>
    <div v-else class="loading">Loading editor…</div>
  </ClientOnly>
</template>

<script setup lang="ts">
import { computed, onMounted, reactive, ref, shallowRef, type Component } from 'vue';

const form = reactive({
  title: '',
  body: '',
});
const locked = ref(false);

const titleError = computed(() => (form.title.length === 0 ? 'Required.' : ''));
const bodyError = computed(() =>
  form.body.trim().length === 0 ? 'Body cannot be empty.' : '',
);
const preview = computed(() => JSON.stringify(form, null, 2));

function onSubmit() {
  if (titleError.value || bodyError.value) return;
  // eslint-disable-next-line no-console
  console.log('submit', form);
}

function onReset() {
  form.title = '';
  form.body = '';
}

const Editor = shallowRef<Component | null>(null);
const FormField = shallowRef<Component | null>(null);
const TextInput = shallowRef<Component | null>(null);
const Button = shallowRef<Component | null>(null);

onMounted(async () => {
  const [mod, ui] = await Promise.all([
    import('@cocoar/vue-markdown-editor'),
    import('@cocoar/vue-ui'),
  ]);
  Editor.value = mod.CoarMarkdownEditor;
  FormField.value = ui.CoarFormField;
  TextInput.value = ui.CoarTextInput;
  Button.value = ui.CoarButton;
});
</script>

<style scoped>
.form-demo {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.md-frame {
  height: 220px;
  border: 1px solid var(--coar-border-neutral);
  border-radius: var(--coar-radius-xl);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.row {
  display: flex;
  align-items: center;
  gap: 8px;
}

.lock {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  margin-left: 12px;
  font-size: 13px;
  cursor: pointer;
}

.preview {
  background: var(--coar-background-neutral-secondary);
  border: 1px solid var(--coar-border-neutral);
  border-radius: var(--coar-radius-xl);
  padding: 12px;
  font-size: 12px;
  color: var(--coar-text-neutral-secondary);
  overflow: auto;
  margin: 0;
}

.loading {
  padding: 24px;
  text-align: center;
  color: var(--coar-text-neutral-tertiary);
  font-size: 13px;
}
</style>
vue
<template>
  <CoarFormField label="Body" :error="bodyError" hint="Markdown" required>
    <CoarMarkdownEditor v-model="form.body" />
  </CoarFormField>
</template>

<script setup lang="ts">
import { computed, reactive } from 'vue';
import { CoarFormField } from '@cocoar/vue-ui';
import { CoarMarkdownEditor } from '@cocoar/vue-markdown-editor';

const form = reactive({ body: '' });
const bodyError = computed(() => form.body.trim().length === 0 ? 'Body cannot be empty.' : '');
</script>

What gets auto-wired from the surrounding <CoarFormField>:

Form-field stateEffect on the editor
idSet as the editor wrapper's id (so <label for="..."> association works)
errorSets aria-invalid="true" and applies the error outline
disabledCombined with the editor's own readonly prop — `disabled
messageIdSet as aria-describedby so screen readers announce the form-field's error/hint when the editor is focused

You can also pass these props directly without CoarFormField (error, disabled, id) — direct props win over the injected context.

Text Color

Apply inline color to a selection. Click the palette button in the floating toolbar (or the sidebar item in fixed mode) to open the picker — pick a swatch from the 8-color palette or use the native browser color input for a custom hex value. The color persists to markdown as plain inline HTML so the document stays readable in any standard renderer:

markdown
The quick <span style="color: #dc2626">red</span> fox.
vue
<template>
  <ClientOnly>
    <div class="md-color-demo">
      <div class="md-color-demo__frame">
        <component :is="Editor" v-if="Editor" v-model="value" />
        <div v-else class="md-color-demo__loading">Loading editor…</div>
      </div>
      <div class="md-color-demo__viewer">
        <div class="md-color-demo__viewer-label">Rendered output (`@cocoar/vue-markdown`)</div>
        <component :is="Viewer" v-if="Viewer && doc" :doc="doc" />
      </div>
    </div>
    <details class="md-output">
      <summary>Raw markdown (v-model)</summary>
      <pre>{{ value }}</pre>
    </details>
  </ClientOnly>
</template>

<script setup lang="ts">
import { computed, onMounted, ref, shallowRef, type Component } from 'vue';

const value = ref(`# Text color round-trip

Select some text and click the **palette** button in the floating toolbar.
Try a swatch, then the custom hex input, then re-select the colored text.

The wire format on disk is plain inline HTML:
<span style="color: #dc2626">red</span>,
<span style="color: #2563eb">blue</span>,
<span style="color: rgb(22, 163, 74)">green via rgb()</span>.

Anything outside the whitelist (other CSS properties, \`url(...)\`, \`var()\`)
is rejected by the sanitizer in both the editor and the viewer.
`);

const Editor = shallowRef<Component | null>(null);
const Viewer = shallowRef<Component | null>(null);
const parse = shallowRef<((md: string) => unknown) | null>(null);

const doc = computed(() => (parse.value ? parse.value(value.value) : null));

onMounted(async () => {
  const [editor, viewer, core] = await Promise.all([
    import('@cocoar/vue-markdown-editor'),
    import('@cocoar/vue-markdown'),
    import('@cocoar/vue-markdown-core'),
  ]);
  Editor.value = editor.CoarMarkdownEditor;
  Viewer.value = viewer.CoarMarkdown;
  parse.value = (md: string) => core.parse(md);
});
</script>

<style scoped>
.md-color-demo {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--coar-spacing-m);
}

.md-color-demo__frame {
  height: 360px;
  border: 1px solid var(--coar-border-neutral);
  border-radius: var(--coar-radius-xl);
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.md-color-demo__loading {
  padding: 24px;
  text-align: center;
  color: var(--coar-text-neutral-tertiary);
  font-size: 13px;
}

.md-color-demo__viewer {
  height: 360px;
  border: 1px solid var(--coar-border-neutral);
  border-radius: var(--coar-radius-xl);
  overflow: auto;
  padding: var(--coar-spacing-m);
  background: var(--coar-background-neutral-primary);
}

.md-color-demo__viewer-label {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--coar-text-neutral-tertiary);
  margin-bottom: var(--coar-spacing-s);
}

.md-output { margin-top: 12px; }
.md-output summary {
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
}
.md-output pre {
  margin-top: 8px;
  padding: 12px;
  background: var(--coar-background-neutral-secondary);
  border-radius: var(--coar-radius-xl);
  font-size: 12px;
  max-height: 200px;
  overflow: auto;
  white-space: pre-wrap;
}

@media (max-width: 720px) {
  .md-color-demo {
    grid-template-columns: 1fr;
  }
}
</style>

The picker is rendered through the same overlay primitive (menuPreset) that powers menus, popovers, and sidebar flyouts: anchor-relative positioning, viewport flipping, scroll-reposition, plus outside-click and Escape dismissal — no bespoke layout or click-handling logic in the editor.

Why a whitelist?

The viewer (@cocoar/vue-markdown and @cocoar/vue-markdown-core) and the editor share a single sanitizeColor helper that accepts only:

  • Hex (#rgb, #rrggbb, with optional alpha)
  • rgb() / rgba() and the modern space-separated form
  • hsl() / hsla() and the modern space-separated form
  • A small set of named CSS colors (red, blue, …, transparent, currentcolor)

Anything else — var(--token), url(...), expression(...), multi-declaration styles, foreign attributes — is rejected. A failed sanitization falls through to plain text in the viewer and keeps the surrounding content intact in the editor. There's no way for a hostile markdown payload to leak inline style beyond a single color declaration.

The picker palette (COAR_TEXT_COLOR_PALETTE) is exported so consumers can mirror it in custom UI.

Editor ↔ Viewer Parity

<CoarMarkdownEditor> and <CoarMarkdown> (the viewer) read the same shared stylesheet (@cocoar/vue-markdown/styles) so a markdown document looks pixel-identical whether you're editing it or rendering it for display. The two render through different DOM shapes — the editor's PM-managed contenteditable emits bare <li> / <td> / <blockquote> nodes inside a .ProseMirror wrapper, while the viewer emits class-tagged elements (.coar-markdown-list-item, etc.) — and the shared stylesheet covers both via parallel :where(…) selectors:

ConcernNote
Vertical rhythmBlock margins apply to direct children of .coar-markdown (viewer) and .coar-markdown .ProseMirror (editor).
TypographyHeading sizes, blockquote inset, list indentation, <strong> weight (700), inline-code color, link underline — all defined once.
TablesZebra alternation uses :nth-child(<n> of :not([data-is-header])) to handle Milkdown's <tr data-is-header>-inside-<tbody> shape and the viewer's classic <thead> / <tbody> split with one rule.
Task lists<li data-item-type="task" data-checked="true"> in both panes; the visual checkbox is a ::before pseudo-element (no native <input>). Completed items get the muted-color strikethrough.
Cell padding<p> user-agent margin reset to 0 inside <li> / <td> / <th> — without the reset PM's auto-wrapped paragraph would add ~1em of vertical whitespace per row.

If you embed the editor next to a viewer pane (the playground's "viewer pane" toggle does exactly this), the two should render the same source identically. Differences narrow down to design tokens you can override globally:

VariableDefaultEffect
--coar-markdown-heading-block-startvar(--coar-spacing-xl, 2rem)Extra space above every top-level heading. Lower for tighter docs, raise for more whitespace.
--coar-markdown-space-2var(--coar-spacing-m, 1rem)Default block-end margin. Drives paragraph / list / table / blockquote spacing.
--coar-markdown-linkvar(--coar-text-brand-primary)Link color (also applied to inline code).
--coar-markdown-bordervar(--coar-border-neutral-tertiary)Used by tables, blockquote, <hr>.

Restricting the Toolbar

Pass a tools array to limit which buttons the toolbar exposes. When omitted, all tools are shown. Order does not matter — the canonical order is preserved.

vue
<!-- Minimal subset (matches the default rich-text editor in older Cocoar apps) -->
<CoarMarkdownEditor
  v-model="value"
  :tools="['bold', 'italic', 'bulletList', 'orderedList', 'outdent', 'indent', 'clearFormatting']"
/>

<!-- All tools except tables -->
<script setup lang="ts">
import { COAR_MARKDOWN_EDITOR_ALL_TOOLS } from '@cocoar/vue-markdown-editor';
const tools = COAR_MARKDOWN_EDITOR_ALL_TOOLS.filter(t => t !== 'table' && t !== 'tableOps');
</script>
<CoarMarkdownEditor v-model="value" :tools="tools" />

Tool identifiers

ToolDescription
bold italic strikethrough inlineCodeInline marks
textColorText color picker — see Text Color
headingsHeading flyout (H1–H6 + paragraph)
bulletList orderedList taskListList variants
indent outdentList nesting controls
blockquote horizontalRuleBlock elements
codeBlock tableInsert blocks
tableOpsInsert/Delete row/col, shown contextually when cursor is inside a table
clearFormattingStrip all marks + reset block to paragraph
undo redoHistory

Markdown-only formatting

Only formatting that round-trips through Markdown is exposed. There is intentionally no underline, font-family, font-size, or alignment — these have no Markdown representation and would silently break round-trip persistence. Text color is the one exception: it round-trips as plain inline HTML through a strict whitelist sanitizer (see Text Color).

When migrating from a richtext editor that exposed those tools, the closest Markdown-native substitutes are:

Richtext toolMarkdown equivalent
Font sizeheadings — H1–H6 provide the typographic hierarchy
Bold / italicbold / italic (no change)
Bulleted / numbered listbulletList / orderedList
Indent / outdent (in lists)indent / outdent
Clear / eraserclearFormatting
Underline, color, alignment, font-familyno equivalent — drop or accept embedded HTML

Props

PropTypeDefaultDescription
modelValuestring''Markdown content (use with v-model)
readonlybooleanfalseDisable editing (keeps the layout, suppresses the toolbar)
disabledbooleanfalseDisabled state — non-interactive, dimmed. Auto-picked up from CoarFormField
errorbooleanfalseError state — adds outline + aria-invalid. Auto-picked up from CoarFormField.error
idstring(auto)HTML id. Auto-generated if omitted; CoarFormField's id takes precedence
namestringundefinedReflected as data-name for form-submission tooling
requiredbooleanfalseSets aria-required="true"
toolbarMode'floating' | 'fixed' | 'both''floating'Toolbar layout
toolbarPosition'left' | 'right' | 'top' | 'bottom''left'Toolbar edge when toolbarMode is 'fixed' or 'both'. top/bottom render a horizontal toolbar; flyouts open along the perpendicular axis.
toolsCoarMarkdownEditorTool[]allWhitelist of toolbar tools. See Restricting the Toolbar

Events

EventPayloadDescription
update:modelValuestringFired on every internal markdown change. The editor de-duplicates — if a parent echoes the value back unchanged, no second update fires.

Floating-Toolbar Contexts

The floating toolbar swaps its contents based on what's selected:

SelectionToolbar
Text outside a tableBold, Italic, Strikethrough, Inline Code, Headings flyout, Blockquote
Text inside a table cellRow insert above/below, Column insert left/right, Delete cell, plus Bold/Italic/Code

Detection runs on the ProseMirror selection state via editorViewCtx. CellSelections are ProseMirror-internal and don't fire selectionchange — the column- and row-handle toolbars referenced in the architecture below are not wired up yet (see TODO).

Architecture Notes

Why Milkdown (not TipTap, not Crepe)

Milkdown KitTipTapMilkdown Crepe
Data formatMarkdown-first (lossless round-trip)JSON-first (lossy markdown export)Markdown-first
Shared stackSame as @cocoar/vue-markdown-core: unified@^11, remark-parse@^11, remark-gfm@^4No overlapSame
Bundle~137 KB gzip (Kit)Similar~2 MB (CodeMirror, KaTeX, etc.)
UI controlFull — headless, own componentsFull — headlessLimited — predefined Notion-like UI
LicenseMITMIT (core), paid (collab)MIT

The Kit approach gives full control over UI while sharing the remark pipeline with the existing @cocoar/vue-markdown-core parser and <CoarMarkdown> viewer.

Why no tableBlock plugin

@milkdown/components/table-block provides edge-handle buttons, button-group popups, and drag-to-reorder, but:

  • Clicking in a cell auto-selects all content (breaks normal editing)
  • Button-group popup overlaps with the floating toolbar
  • CellSelection is internal to ProseMirror — window.getSelection() returns type: "None", so selectionchange doesn't fire and detection is unreliable
  • Requires ~200 lines of CSS to style properly

Table operations are instead exposed via the floating toolbar (when the cursor is inside a cell) and the sidebar toolbar (always available in fixed mode). Custom edge-handles will be built when a stable design is settled.

TODO

  • [ ] Custom table edge-handles (column/row selection + dedicated toolbars)
  • [ ] Link insert/edit dialog
  • [ ] Image upload support
  • [ ] Placeholder text
  • [ ] Task list checkbox rendering and toggling
  • [ ] Use computeOverlayCoordinates for floating toolbar positioning instead of viewport clamping
  • [ ] Slash commands for block insertions
  • [ ] Block drag handle
  • [ ] Code block syntax highlighting (Prism or Shiki)

Released under the Apache-2.0 License.