# milkdown-element

Milkdown Crepe markdown editor packaged as a standalone web component. All dependencies (including CodeMirror, ProseMirror, Mermaid, etc.) are bundled into a single browser-ready ESM file.

Includes a read-only viewer component (`milkdown-viewer-element`) for displaying authored content without editing capabilities.

## Install

```bash
npm install milkdown-element
```

## Usage

```html
<script type="module">
  import 'milkdown-element';
</script>

<milkdown-element doc="# Hello world"></milkdown-element>
```

### Placeholder

```html
<milkdown-element placeholder="Start typing..."></milkdown-element>
```

### Listening for changes

```html
<milkdown-element id="editor"></milkdown-element>
<script type="module">
  import 'milkdown-element';

  const editor = document.querySelector('#editor');
  editor.addEventListener('input', () => {
    console.log(editor.getMarkdown());
  });
</script>
```

### With Lit

```ts
import 'milkdown-element';

html`
  <milkdown-element
    .doc=${this.markdown}
    placeholder="Start typing..."
    .onUpload=${(file: File) => this.uploadImage(file)}
    .onFileUpload=${(file: File) => this.uploadFile(file)}
    @input=${this.#onInput}
  ></milkdown-element>
`;
```

### Viewer (read-only)

Use `milkdown-viewer-element` to render authored content without the toolbar, slash menu, or editing capabilities:

```html
<milkdown-viewer-element .doc=${this.markdown}></milkdown-viewer-element>
```

The viewer renders all content types (images, iframes, mermaid diagrams, file blocks, code blocks, highlights, emoji, tables, and LaTeX) but disables all editing interactions.

## Features

The editor ships with these built-in content types on top of standard markdown:

| Feature | Description |
| --- | --- |
| **Toolbar** | Floating formatting toolbar appears when text is selected |
| **Slash menu** | Type `/` on an empty line to open a block insertion menu |
| **Image upload** | Drag-and-drop or paste images; uses the `onUpload` callback |
| **File attachment** | Attach downloadable files via the slash menu; uses `onFileUpload` |
| **Iframe embed** | Embed external pages; available via slash menu or by typing `::iframe{src="url"}` |
| **Mermaid diagrams** | Create flowcharts and diagrams; available via slash menu or `` ```mermaid `` |
| **Highlight** | Highlight text with `==highlighted text==` or the toolbar button |
| **Emoji** | Standard emoji support |
| **Tables** | Insert and edit tables via the slash menu |
| **Code blocks** | Syntax-highlighted code blocks with language selection |
| **LaTeX** | Inline and block math via LaTeX syntax |
| **Lists** | Bullet, ordered, and task lists |
| **Links** | Paste URLs or use the link tooltip to add hyperlinks |

## Custom Plugins

Both `milkdown-element` and `milkdown-viewer-element` accept a `plugins` property for registering custom [Milkdown plugins](https://milkdown.dev/docs/plugin). The editor also accepts `onBuildMenu` to add items to the `/` slash menu.

```ts
import type { MilkdownPlugin } from 'milkdown-element';
```

### Quick start

```ts
html`
  <milkdown-element
    .plugins=${myPlugins}
    .onBuildMenu=${myBuildMenu}
  ></milkdown-element>

  <milkdown-viewer-element
    .plugins=${myViewerPlugins}
  ></milkdown-viewer-element>
`;
```

### Building a plugin

A plugin is an array of `MilkdownPlugin` items created with helpers from `@milkdown/kit/utils`. At minimum you need a **node schema** (what the block looks like in ProseMirror) and a **remark transform** (how it maps to/from markdown).

#### 1. Define the markdown format

Pick a markdown representation for your block. A fenced code block with a custom language is the easiest approach since it doesn't conflict with any built-in syntax:

````markdown
```my-widget
some serialized data
```
````

#### 2. Write a remark transform

Convert fenced code blocks with your language into a custom AST node type before Milkdown parses them:

```ts
import { $remark } from '@milkdown/kit/utils';
import { visit, SKIP } from 'unist-util-visit';

function remarkMyWidget() {
  return (tree: any) => {
    visit(tree, 'code', (node: any, index: number | undefined, parent: any) => {
      if (node.lang !== 'my-widget' || index == null || !parent) return;
      parent.children.splice(index, 1, {
        type: 'myWidget',
        data: node.value,
      });
      return [SKIP, index] as const;
    });
  };
}

const remarkPlugin = $remark('remarkMyWidget', () => remarkMyWidget as never);
```

#### 3. Define the node schema

Use `$node` to describe how the node is parsed from markdown, serialized back, and rendered in the DOM. The `toDOM` return value controls what element appears in the editor:

```ts
import { $node } from '@milkdown/kit/utils';

const myWidgetNode = $node('myWidget', () => ({
  group: 'block',
  atom: true,
  isolating: true,
  marks: '',
  attrs: { data: { default: '' } },
  parseDOM: [
    {
      tag: 'my-widget',
      getAttrs: (dom: HTMLElement) => ({ data: dom.getAttribute('data') ?? '' }),
    },
  ],
  toDOM: (node) => ['my-widget', { data: node.attrs.data, contenteditable: 'false' }],
  parseMarkdown: {
    match: (node) => node.type === 'myWidget',
    runner: (state, node, type) => {
      state.addNode(type, { data: node.data });
    },
  },
  toMarkdown: {
    match: (node) => node.type.name === 'myWidget',
    runner: (state, node) => {
      state.addNode('code', undefined, node.attrs.data, { lang: 'my-widget' });
    },
  },
}));
```

#### 4. Assemble the plugin array

Flatten everything into a single `MilkdownPlugin[]` — the remark, node, and any input rules or prose plugins:

```ts
import type { MilkdownPlugin } from '@milkdown/ctx';

export const myWidgetPlugin: MilkdownPlugin[] = [
  remarkPlugin,
  myWidgetNode,
].flat();
```

#### 5. Add a slash menu item (optional)

Export a `buildMenu` function and pass it to the editor's `onBuildMenu` property:

```ts
import { commandsCtx, editorViewCtx } from '@milkdown/kit/core';
import { clearTextInCurrentBlockCommand } from '@milkdown/kit/preset/commonmark';

export function myWidgetBuildMenu(groupBuilder: any) {
  try {
    const group = groupBuilder.getGroup('advanced');
    group.addItem('my-widget', {
      label: 'My Widget',
      icon: '<svg>...</svg>',
      onRun: (ctx: any) => {
        const commands = ctx.get(commandsCtx);
        const view = ctx.get(editorViewCtx);
        commands.call(clearTextInCurrentBlockCommand.key);
        const { from } = view.state.selection;
        const tr = view.state.tr.replaceWith(
          from, from,
          myWidgetNode.type(ctx).create({ data: '' }),
        );
        view.dispatch(tr);
      },
    });
  } catch {
    // group may not exist
  }
}
```

```ts
html`<milkdown-element .onBuildMenu=${myWidgetBuildMenu}></milkdown-element>`;
```

### Syncing interactive components with a NodeView

If your web component is interactive (buttons, inputs, etc.), the default `toDOM` rendering won't propagate changes back to ProseMirror. Use a `$prose` plugin to register a [NodeView](https://prosemirror.net/docs/ref/#view.NodeView) that listens for events from your component and updates the document:

```ts
import { $prose } from '@milkdown/kit/utils';
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';

const myWidgetNodeView = $prose(() => new Plugin({
  key: new PluginKey('MY_WIDGET_VIEW'),
  props: {
    nodeViews: {
      myWidget: (node, view, getPos) => {
        const dom = document.createElement('my-widget');
        dom.setAttribute('data', node.attrs.data);
        dom.setAttribute('contenteditable', 'false');

        dom.addEventListener('widget-change', (e: Event) => {
          const { data } = (e as CustomEvent).detail;
          const pos = getPos();
          if (pos === undefined) return;
          view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, { data }));
        });

        return {
          dom,
          update(updatedNode) {
            if (updatedNode.type.name !== 'myWidget') return false;
            dom.setAttribute('data', updatedNode.attrs.data);
            return true;
          },
          stopEvent: () => true,
          ignoreMutation: () => true,
        };
      },
    },
  },
}));
```

Add it to your plugin array:

```ts
export const myWidgetPlugin: MilkdownPlugin[] = [
  remarkPlugin,
  myWidgetNode,
  myWidgetNodeView,
].flat();
```

Your web component dispatches the event whenever its state changes:

```ts
this.dispatchEvent(new CustomEvent('widget-change', {
  detail: { data: this.data },
  bubbles: true,
  composed: true,
}));
```

### Different components for editor vs viewer

You can provide separate plugin arrays to the editor and viewer so each renders a different web component for the same node. For example, an interactive admin component in the editor and a read-only display in the viewer:

```ts
// Editor plugin: renders <my-widget-admin>
const editorNode = $node('myWidget', () => ({
  /* ...same schema... */
  toDOM: (node) => ['my-widget-admin', { data: node.attrs.data, contenteditable: 'false' }],
}));

// Viewer plugin: renders <my-widget-display>
const viewerNode = $node('myWidget', () => ({
  /* ...same schema... */
  toDOM: (node) => ['my-widget-display', { data: node.attrs.data, contenteditable: 'false' }],
}));

export const editorPlugin: MilkdownPlugin[] = [remarkPlugin, editorNode].flat();
export const viewerPlugin: MilkdownPlugin[] = [remarkPlugin, viewerNode].flat();
```

```ts
html`
  <milkdown-element .plugins=${editorPlugin}></milkdown-element>
  <milkdown-viewer-element .plugins=${viewerPlugin}></milkdown-viewer-element>
`;
```

Both share the same remark transform and markdown serialization, so the data round-trips correctly between them. See the `demo/` folder for a complete working example.

## API

### `milkdown-element`

| Property | Attribute | Type | Description |
| --- | --- | --- | --- |
| `doc` | `doc` | `string` | Markdown content to edit |
| `placeholder` | `placeholder` | `string` | Placeholder text shown when the editor is empty |
| `onUpload` | — | `(file: File) => Promise<string>` | Callback to upload an image; should return the image URL |
| `onFileUpload` | — | `(file: File) => Promise<{ url: string; name: string }>` | Callback to upload a file; should return the download URL and display name |
| `resolveImageUrl` | — | `(url: string) => string \| Promise<string>` | Optional URL resolver for image display (e.g. CDN proxy) |
| `plugins` | — | `MilkdownPlugin[]` | Custom Milkdown plugins to register with the editor |
| `onBuildMenu` | — | `(builder: unknown) => void` | Callback to add items to the `/` slash menu |
| `crepe` | — | `Crepe \| null` | The underlying Crepe instance (read-only at runtime) |

| Method | Returns | Description |
| --- | --- | --- |
| `getMarkdown()` | `string` | Returns current editor markdown |
| `getImageSrcs()` | `string[]` | Returns all image URLs found in the current markdown |
| `getFileSrcs()` | `string[]` | Returns all file URLs found in the current markdown |
| `reset()` | `void` | Clears the editor and re-initializes |

| Event | Detail | Description |
| --- | --- | --- |
| `input` | — | Fired when markdown content changes |

### `milkdown-viewer-element`

| Property | Attribute | Type | Description |
| --- | --- | --- | --- |
| `doc` | `doc` | `string` | Markdown content to display |
| `resolveImageUrl` | — | `(url: string) => string \| Promise<string>` | Optional URL resolver for image display |
| `plugins` | — | `MilkdownPlugin[]` | Custom Milkdown plugins to register with the viewer |

### Accessing the Crepe instance

The underlying `Crepe` instance is available via the `crepe` property. It is `null` until the editor finishes initializing.

```ts
const el = document.querySelector('milkdown-element');
await el.updateComplete;
el.crepe; // Crepe instance
```

The `Crepe` and `MilkdownPlugin` types are re-exported for TypeScript consumers:

```ts
import type { Crepe, MilkdownPlugin } from 'milkdown-element';
```

## Development

```bash
npm run setup    # install deps for library + demo
npm run demo     # start the demo dev server (opens browser)
```

The demo imports directly from `src/` via a Vite alias, so changes to the library source are reflected immediately with HMR.

## Build

```bash
npm run build    # one-shot production build
npm run dev      # rebuild on file changes (watch mode)
```
