npm package: @mkteagle/mdx-wysiwyg
Description: Headless WYSIWYG editor with MDX serialization, plugin system, and composable React components
Latest version: 1.0.0
Deprecated: This package has been moved to crucible-ui. Install crucible-ui
   and import from crucible-ui/mdx instead.

# @mkteagle/mdx-wysiwyg

A headless WYSIWYG editor with MDX serialization, a plugin system, and composable React components.

## Install

```bash
npm install @mkteagle/mdx-wysiwyg
# or
pnpm add @mkteagle/mdx-wysiwyg
```

## Quick Start

```tsx
import { EditorProvider, WysiwygEditor } from "@mkteagle/mdx-wysiwyg";
import "@mkteagle/mdx-wysiwyg/styles";

function MyEditor() {
  const [content, setContent] = useState("");

  return (
    <EditorProvider>
      <WysiwygEditor value={content} onChange={setContent} />
    </EditorProvider>
  );
}
```

This gives you a working editor with formatting (bold, italic, headings, lists, links, blockquotes) and slash commands out of the box. No configuration required.

## Configuration

Pass an `EditorConfig` to enable additional features:

```tsx
import {
  EditorProvider,
  WysiwygEditor,
  youtubePlugin,
  appleMusicPlugin,
  imagePlugin,
  type EditorConfig,
} from "@mkteagle/mdx-wysiwyg";

const config: EditorConfig = {
  plugins: [youtubePlugin, appleMusicPlugin, imagePlugin],
  adapters: {
    upload: {
      uploadImage: async (file) => {
        // Upload to YOUR server, return an ID or URL
        const formData = new FormData();
        formData.append("file", file);
        const res = await fetch("/your-api/upload", { method: "POST", body: formData });
        const { id } = await res.json();
        return id;
      },
    },
    media: {
      fetchMedia: async (id) => {
        // Resolve an ID back to a URL from YOUR server
        const res = await fetch(`/your-api/media?id=${id}`);
        const { url } = await res.json();
        return { url };
      },
    },
  },
};

function MyEditor() {
  const [content, setContent] = useState("");

  return (
    <EditorProvider config={config}>
      <WysiwygEditor value={content} onChange={setContent} />
    </EditorProvider>
  );
}
```

### Key Principle

**The editor never talks to any external service directly.** All network calls go through adapter functions that you provide. Your API keys, tokens, and endpoints stay in your code. The package just calls the functions you give it.

---

## Adapters

Adapters handle I/O operations (uploading files, resolving media URLs). You provide them; the editor calls them.

### `UploadAdapter`

Handles image uploads. Required for the image plugin to insert new images.

```tsx
interface UploadAdapter {
  uploadImage: (file: File, metadata?: Record<string, unknown>) => Promise<string>;
  compressImage?: (file: File, options: CompressOptions) => Promise<File>;
  mediaLimits?: { maxSizeBytes: number; maxWidth: number };
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `uploadImage` | Yes | Takes a `File`, uploads it to your backend, returns an ID or URL string. |
| `compressImage` | No | Client-side compression before upload. |
| `mediaLimits` | No | Limits for file size and dimensions. |

**Example: Upload to your own API**

```tsx
const upload: UploadAdapter = {
  uploadImage: async (file) => {
    const form = new FormData();
    form.append("file", file);
    const res = await fetch("/api/media/upload", { method: "POST", body: form });
    const data = await res.json();
    return data.id; // or data.url
  },
  mediaLimits: { maxSizeBytes: 8 * 1024 * 1024, maxWidth: 2800 },
};
```

**Example: Upload to Vercel Blob**

```tsx
import { upload as vercelUpload } from "@vercel/blob/client";

const upload: UploadAdapter = {
  uploadImage: async (file) => {
    const blob = await vercelUpload(file.name, file, {
      access: "public",
      handleUploadUrl: "/api/blob/upload",
    });
    return blob.url;
  },
};
```

**Example: Upload to Cloudflare R2 (via your API)**

```tsx
const upload: UploadAdapter = {
  uploadImage: async (file) => {
    const form = new FormData();
    form.append("file", file);
    const res = await fetch("/api/r2/upload", {
      method: "POST",
      body: form,
      credentials: "include",
    });
    const { id } = await res.json();
    return id;
  },
};
```

### `MediaAdapter`

Resolves media IDs back to URLs. Required for images to display in the editor.

```tsx
interface MediaAdapter {
  fetchMedia: (id: string) => Promise<{ url: string | null; thumbnailUrl?: string | null }>;
}
```

**Example: Resolve from your API**

```tsx
const media: MediaAdapter = {
  fetchMedia: async (id) => {
    const res = await fetch(`/api/media/resolve?id=${encodeURIComponent(id)}`);
    const data = await res.json();
    return { url: data.url, thumbnailUrl: data.thumbnailUrl };
  },
};
```

**Example: ID is already a URL (no resolution needed)**

```tsx
const media: MediaAdapter = {
  fetchMedia: async (id) => ({ url: id }),
};
```

If your `uploadImage` returns a full URL (like Vercel Blob does), use the identity resolver above. If it returns an opaque ID (like a database key), you need a resolver that looks up the URL.

---

## Built-in Plugins

### `youtubePlugin`

Adds YouTube embed support. Paste a YouTube URL and it auto-embeds. Also adds a `/youtube` slash command.

```tsx
import { youtubePlugin } from "@mkteagle/mdx-wysiwyg";

const config: EditorConfig = {
  plugins: [youtubePlugin],
};
```

Serializes to: `<YouTube videoId="dQw4w9WgXcQ" />`

### `appleMusicPlugin`

Adds Apple Music embed support. Paste an Apple Music URL and it auto-embeds. Also adds an `/applemusic` slash command.

```tsx
import { appleMusicPlugin } from "@mkteagle/mdx-wysiwyg";

const config: EditorConfig = {
  plugins: [appleMusicPlugin],
};
```

Serializes to: `<AppleMusic trackId="123456789" type="album" />`

### `imagePlugin`

Adds image display and hydration. Requires a `MediaAdapter` to resolve image IDs to URLs.

```tsx
import { imagePlugin } from "@mkteagle/mdx-wysiwyg";

const config: EditorConfig = {
  plugins: [imagePlugin],
  adapters: {
    upload: { uploadImage: myUploadFn },
    media: { fetchMedia: myResolveFn },
  },
};
```

Serializes to: `<Media id="abc123" kind="PHOTO" caption="My image" align="center" size="medium" />`

### Excluding Commands

Disable specific slash commands without removing the plugin:

```tsx
const config: EditorConfig = {
  plugins: [youtubePlugin, appleMusicPlugin, imagePlugin],
  excludeCommands: ["applemusic"], // Hide from slash menu
};
```

---

## Custom Plugins

The plugin system lets you add any custom content type to the editor without modifying the package. Each plugin can contribute slash commands, handle paste events, and provide its own React component for picking/inserting content.

### `EditorPlugin` Interface

```tsx
interface EditorPlugin {
  name: string;
  commands?: SlashCommand[];
  onCommand?: (action: string, context: PluginCommandContext) => boolean;
  picker?: ComponentType<PluginPickerProps>;
  serializeNode?: (el: HTMLElement) => string | null;
  deserializeLine?: (line: string, helpers: DeserializeHelpers) => string | null;
  hydrateNodes?: (root: HTMLElement, adapters: EditorAdapters) => Promise<void>;
  handlePaste?: (text: string, context: PasteContext) => boolean;
}
```

| Field | Description |
|-------|-------------|
| `name` | Unique identifier for the plugin. |
| `commands` | Slash commands to add to the `/` menu. |
| `onCommand` | Called when a slash command fires. Return `true` if handled. |
| `picker` | React component shown when the slash command is selected. Receives `onInsert(node)`, `onClose()`, and `serializeNow()`. |
| `serializeNode` | Convert a DOM element to a string when saving. Return `null` to skip. |
| `deserializeLine` | Parse a content line into HTML when loading. Return `null` to skip. |
| `hydrateNodes` | Initialize DOM elements after content is loaded (apply styles, fetch data, etc). |
| `handlePaste` | Intercept paste events to handle plugin-specific URLs or content. Return `true` if handled. |

### Plugin Lifecycle

```
Loading content:
  MDX string → deserializeLine() → HTML in editor → hydrateNodes()

Saving content:
  DOM elements → serializeNode() → MDX string

User interaction:
  Slash command → onCommand() or picker component → insertNodeAtSelection()
  Paste URL → handlePaste() → insertNodeAtSelection()
```

### How `onCommand` vs `picker` Work

When a slash command fires, the editor checks your plugin in this order:

1. If `onCommand` exists and returns `true`, the action is handled (good for simple inserts that don't need UI).
2. If `picker` exists and one of the plugin's `commands` matches the action, the picker component is opened (good for search dialogs, selection UIs, etc).
3. Otherwise, the editor falls through to built-in handlers.

### Example: Vinyl Record Plugin (with Picker)

A complete custom plugin that lets users search and embed vinyl records:

```tsx
// vinyl-plugin.tsx
import type { EditorPlugin, PluginPickerProps } from "@mkteagle/mdx-wysiwyg";

// Your React component — uses YOUR API, YOUR auth, YOUR data
function VinylPicker({ onInsert, onClose }: PluginPickerProps) {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);

  const search = async () => {
    const res = await fetch(`/api/vinyl/search?q=${query}`);
    setResults(await res.json());
  };

  const select = (record: { id: number; title: string; artist: string }) => {
    const el = document.createElement("div");
    el.contentEditable = "false";
    el.dataset.vinylId = String(record.id);
    el.dataset.vinylTitle = record.title;
    el.dataset.vinylArtist = record.artist;
    el.style.cssText =
      "padding:12px;border:1px solid #ddd;border-radius:8px;margin:8px 0;";
    el.innerHTML = `<strong>${record.artist}</strong> &mdash; ${record.title}`;
    onInsert(el);
  };

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
      <div className="bg-white rounded-lg p-6 w-96 space-y-3">
        <h3>Search Vinyl</h3>
        <input value={query} onChange={(e) => setQuery(e.target.value)} />
        <button onClick={search}>Search</button>
        <ul>
          {results.map((r) => (
            <li key={r.id}>
              <button onClick={() => select(r)}>{r.artist} — {r.title}</button>
            </li>
          ))}
        </ul>
        <button onClick={onClose}>Cancel</button>
      </div>
    </div>
  );
}

export const vinylPlugin: EditorPlugin = {
  name: "vinyl",

  commands: [
    { label: "Vinyl Record", icon: "/icons/vinyl.svg", action: "vinyl" },
  ],

  picker: VinylPicker,

  serializeNode(el) {
    if (el.dataset.vinylId) {
      return `<Vinyl id="${el.dataset.vinylId}" title="${el.dataset.vinylTitle}" artist="${el.dataset.vinylArtist}" />`;
    }
    return null;
  },

  deserializeLine(line) {
    const match = line.match(
      /<Vinyl\s+id="([^"]+)"\s+title="([^"]+)"\s+artist="([^"]+)"\s*\/>/
    );
    if (match) {
      const [, id, title, artist] = match;
      return `<div contenteditable="false" data-vinyl-id="${id}" data-vinyl-title="${title}" data-vinyl-artist="${artist}" style="padding:12px;border:1px solid #ddd;border-radius:8px;margin:8px 0;"><strong>${artist}</strong> &mdash; ${title}</div>`;
    }
    return null;
  },

  async hydrateNodes(root) {
    root.querySelectorAll("[data-vinyl-id]").forEach((node) => {
      const el = node as HTMLElement;
      el.contentEditable = "false";
    });
  },
};
```

Register it:

```tsx
const config: EditorConfig = {
  plugins: [imagePlugin, youtubePlugin, vinylPlugin],
  adapters: { upload, media },
};
```

### Example: Simple Inline Plugin (No Picker)

For plugins that don't need a search dialog, use `onCommand` to insert content directly:

```tsx
export const dividerPlugin: EditorPlugin = {
  name: "custom-divider",

  commands: [
    { label: "Fancy Divider", icon: "/icons/divider.svg", action: "fancy-divider" },
  ],

  onCommand(action, context) {
    if (action !== "fancy-divider") return false;
    const hr = document.createElement("div");
    hr.contentEditable = "false";
    hr.dataset.fancyDivider = "true";
    hr.innerHTML = "&#10022; &#10022; &#10022;";
    hr.style.cssText = "text-align:center;padding:16px 0;color:#ccc;";
    context.insertNodeAtSelection(hr);
    return true;
  },

  serializeNode(el) {
    if (el.dataset.fancyDivider) return "<FancyDivider />";
    return null;
  },

  deserializeLine(line) {
    if (line.trim() === "<FancyDivider />") {
      return '<div contenteditable="false" data-fancy-divider="true" style="text-align:center;padding:16px 0;color:#ccc;">&#10022; &#10022; &#10022;</div>';
    }
    return null;
  },

  async hydrateNodes(root) {
    root.querySelectorAll("[data-fancy-divider]").forEach((el) => {
      (el as HTMLElement).contentEditable = "false";
    });
  },
};
```

### Example: Paste Handler Plugin

Detect specific URLs on paste and auto-embed them:

```tsx
export const spotifyPlugin: EditorPlugin = {
  name: "spotify",

  handlePaste(text, context) {
    const match = text.match(/open\.spotify\.com\/track\/([a-zA-Z0-9]+)/);
    if (!match) return false;

    const trackId = match[1];
    const el = document.createElement("div");
    el.contentEditable = "false";
    el.dataset.spotifyId = trackId;
    el.innerHTML = `<iframe src="https://open.spotify.com/embed/track/${trackId}" width="100%" height="80" frameborder="0"></iframe>`;
    context.insertNodeAtSelection(el);
    context.serializeNow();
    return true;
  },

  serializeNode(el) {
    if (el.dataset.spotifyId) return `<Spotify trackId="${el.dataset.spotifyId}" />`;
    return null;
  },

  deserializeLine(line) {
    const match = line.match(/<Spotify\s+trackId="([^"]+)"\s*\/>/);
    if (match) {
      return `<div contenteditable="false" data-spotify-id="${match[1]}"><iframe src="https://open.spotify.com/embed/track/${match[1]}" width="100%" height="80" frameborder="0"></iframe></div>`;
    }
    return null;
  },

  async hydrateNodes(root) {
    root.querySelectorAll("[data-spotify-id]").forEach((el) => {
      (el as HTMLElement).contentEditable = "false";
    });
  },
};
```

---

## Slots

Slots let you inject custom UI components into specific positions in the editor.

```tsx
const config: EditorConfig = {
  slots: {
    emojiPicker: MyEmojiPicker,       // Renders in the toolbar
    appleMusicPicker: MyMusicPicker,  // Opens when Apple Music command fires
  },
};
```

### `emojiPicker`

```tsx
interface EmojiPickerSlotProps {
  onSelect: (emoji: string) => void;
}
```

### `appleMusicPicker`

```tsx
interface AppleMusicPickerSlotProps {
  onSelect: (item: {
    id: string;
    type: "song" | "album";
    name?: string;
    artistName?: string;
    artworkUrl?: string;
  }) => void;
  onClose: () => void;
}
```

---

## Styling

Import the base styles, then override with class names or CSS variables.

### CSS Import

```tsx
import "@mkteagle/mdx-wysiwyg/styles";
```

### Custom Class Names

Override any part of the editor UI:

```tsx
const config: EditorConfig = {
  classNames: {
    root: "my-editor-root",
    toolbar: "my-toolbar",
    editor: "my-editor-content",
    placeholder: "my-placeholder",
    slashMenu: "my-slash-menu",
  },
};
```

### CSS Variables

The editor reads these CSS variables for theming:

```css
:root {
  --mdx-editor-bg-primary: #f8f6f3;
  --mdx-editor-border: #dde4ea;
  --mdx-editor-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --mdx-editor-text-muted: #64748b;
  --mdx-editor-text-secondary: #4a5568;
  --mdx-editor-heading-color: #1a2830;
  --mdx-editor-primary: #2563eb;
}
```

---

## Component API

### `<EditorProvider>`

Wraps your editor and provides config via context.

```tsx
<EditorProvider config={config}>
  <WysiwygEditor value={content} onChange={setContent} />
</EditorProvider>
```

### `<WysiwygEditor>`

The main editor component. Can receive config via props or context (props take precedence).

```tsx
interface WysiwygEditorProps {
  value: string;
  onChange: (value: string) => void;
  placeholder?: string;
  config?: EditorConfig;
  classNames?: EditorClassNames;
  className?: string;
  style?: React.CSSProperties;
}
```

### Individual Components

All subcomponents are exported for advanced composition:

- `<Toolbar>` -- Formatting toolbar
- `<SlashMenu>` -- `/` command popup
- `<MediaMenu>` -- Media alignment/sizing popup
- `<LinkPopover>` -- Link editing popup
- `<HeadingMenu>` -- Heading level picker
- `<ListMenu>` -- List type picker

---

## Serialization Format

Content is serialized as MDX-compatible strings:

```mdx
## My Heading

This is a paragraph with **bold** and *italic* text.

- List item one
- List item two

> A blockquote

<YouTube videoId="dQw4w9WgXcQ" />

<Media id="abc123" kind="PHOTO" caption="My photo" align="center" size="medium" />

<Vinyl id="42" title="OK Computer" artist="Radiohead" />
```

Each plugin defines how its content type maps to and from a string. Custom plugins use JSX-like tags (`<ComponentName prop="value" />`) that your frontend can render with MDX or custom components.

## License

MIT