Add documentation for using Milkdown with various frameworks
- Created a new document for using components in Milkdown. - Added a guide for using plugins in Milkdown, including toggling plugins programmatically and listing official plugins. - Introduced a recipe for integrating Milkdown with Angular, including installation steps and component creation. - Added a recipe for using Milkdown with Next.js, detailing installation and component setup. - Created a guide for integrating Milkdown with NuxtJS, including installation and component creation. - Added a comprehensive guide for using Milkdown with React, covering both Crepe and core Milkdown usage. - Introduced a recipe for SolidJS integration with Milkdown, including installation and component creation. - Added a guide for using Milkdown with Svelte, detailing installation and component setup. - Created a comprehensive guide for integrating Milkdown with Vue, covering both Crepe and core Milkdown usage. - Added a recipe for using Milkdown with Vue2, including installation and component creation.
This commit is contained in:
85
milkdown-docs/plugin/composable-plugins.md
Normal file
85
milkdown-docs/plugin/composable-plugins.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Composable Plugins
|
||||
|
||||
In the previous section, we showed you how to create a plugin from scratch. Luckily, you don't need to do that in most cases. Milkdown provides a lot of helpers in [@milkdown/utils](/docs/api/utils) to make it easier to create plugins. The **composable** here means that you can use the plugin in other plugins. For example, you can use a command plugin in a keymap plugin. This is a very common pattern in Milkdown.
|
||||
|
||||
I'll show you some examples of how to use composable plugins. But I won't go into detail about the options and the usage of each plugin. You can find the details in the [API reference](/docs/api/utils#composable).
|
||||
|
||||
## Schema
|
||||
|
||||
The schema plugin is the most important plugin in Milkdown. It defines the structure of the document. A schema plugin in milkdown is a super set of the [node schema spec](https://prosemirror.net/docs/ref/#model.NodeSpec) or [mark schema spec](https://prosemirror.net/docs/ref/#model.MarkSpec) in ProseMirror.
|
||||
|
||||
Let's create a simple blockquote node plugin as an example:
|
||||
|
||||
```typescript
|
||||
import { $node } from "@milkdown/kit/utils";
|
||||
|
||||
const blockquote = $node("blockquote", () => ({
|
||||
content: "block+",
|
||||
group: "block",
|
||||
defining: true,
|
||||
parseDOM: [{ tag: "blockquote" }],
|
||||
toDOM: (node) => ["blockquote", ctx.get(blockquoteAttr.key)(node), 0],
|
||||
parseMarkdown: {
|
||||
match: ({ type }) => type === "blockquote",
|
||||
runner: (state, node, type) => {
|
||||
state.openNode(type).next(node.children).closeNode();
|
||||
},
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === "blockquote",
|
||||
runner: (state, node) => {
|
||||
state.openNode("blockquote").next(node.content).closeNode();
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
## Input Rule
|
||||
|
||||
Since we have a blockquote node, we can create an input rule plugin to make it easier to create a blockquote node.
|
||||
We expect that when we type `> ` at the beginning of a line, the blockquote node will be created.
|
||||
|
||||
```typescript
|
||||
import { wrappingInputRule } from "@milkdown/kit/prose/inputrules";
|
||||
import { $inputRule } from "@milkdown/kit/utils";
|
||||
|
||||
export const wrapInBlockquoteInputRule = $inputRule(() =>
|
||||
wrappingInputRule(/^\s*>\s$/, blockquoteSchema.type()),
|
||||
);
|
||||
```
|
||||
|
||||
## Command
|
||||
|
||||
We can also create a command plugin to create a blockquote node.
|
||||
The command is useful when we want to create a button to create a blockquote node.
|
||||
|
||||
```typescript
|
||||
import { wrapIn } from "@milkdown/kit/prose/commands";
|
||||
import { $command } from "@milkdown/kit/utils";
|
||||
|
||||
export const wrapInBlockquoteCommand = $command(
|
||||
"WrapInBlockquote",
|
||||
() => () => wrapIn(blockquoteSchema.type()),
|
||||
);
|
||||
```
|
||||
|
||||
## Shortcut
|
||||
|
||||
We can also create a shortcut plugin for blockquote.
|
||||
Here we use `Ctrl + Shift + B` as the shortcut. When we press this shortcut, the blockquote node will be created.
|
||||
And we can also use the command we created in the previous section.
|
||||
|
||||
```typescript
|
||||
import { commandsCtx } from "@milkdown/kit/core";
|
||||
import { $useKeymap } from "@milkdown/kit/utils";
|
||||
|
||||
export const blockquoteKeymap = $useKeymap("blockquoteKeymap", {
|
||||
WrapInBlockquote: {
|
||||
shortcuts: "Mod-Shift-b",
|
||||
command: (ctx) => {
|
||||
const commands = ctx.get(commandsCtx);
|
||||
return () => commands.call(wrapInBlockquoteCommand.key);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
125
milkdown-docs/plugin/example-block-plugin.md
Normal file
125
milkdown-docs/plugin/example-block-plugin.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Example: Block Plugin
|
||||
|
||||
The **block plugin** adds a positional hook next to every top-level node (paragraphs, headings, lists, etc.).
|
||||
It is the foundation for features such as drag handles, quick-insert buttons or block toolbars.
|
||||
|
||||
In Milkdown this functionality lives in `@milkdown/plugin-block` and – consistent with tooltip & slash – consists of:
|
||||
|
||||
- a **BlockProvider** that deals with DOM positioning/lifecycle
|
||||
- a **blockFactory** – _implemented internally_ – exposed as two ctx slices: `blockSpec`, `blockPlugin`
|
||||
|
||||
This guide covers:
|
||||
|
||||
- Understanding the provider/service architecture.
|
||||
- Writing a **vanilla TypeScript** drag handle that lets you reorder blocks.
|
||||
- Mounting custom UIs in **React** and **Vue**.
|
||||
- Studying the production-ready _Block Handle_ feature inside Crepe.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anatomy of a Block Plugin
|
||||
|
||||
Unlike tooltip/slash, `@milkdown/plugin-block` ships its factory slices directly:
|
||||
|
||||
```ts
|
||||
import { blockSpec, blockPlugin } from "@milkdown/plugin-block";
|
||||
```
|
||||
|
||||
You normally interact with **BlockProvider** which talks to an internal _BlockService_: the service listens to mouse / drag events, figures out which node is **active** and sends `show` / `hide` messages to the provider.
|
||||
Your job is to decide how to render a UI for that active node.
|
||||
|
||||
Key APIs:
|
||||
|
||||
- `new BlockProvider({ ctx, content, ... })` – similar to Tooltip/Slash.
|
||||
- `provider.active` – info about the currently focused block (`node`, `pos`, `el`).
|
||||
- Optional callbacks: `getOffset`, `getPlacement`, `getPosition` for fine-grained positioning.
|
||||
|
||||
---
|
||||
|
||||
## 2. Minimal Vanilla Drag Handle
|
||||
|
||||
Below we build a small **drag handle** that appears on hover and lets you drag-n-drop any block.
|
||||
|
||||
```ts
|
||||
import { block, blockPlugin } from "@milkdown/plugin-block";
|
||||
import { BlockProvider } from "@milkdown/plugin-block/block-provider"; // path depending on bundler
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
||||
|
||||
// 1️⃣ Create DOM element for the handle
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "drag-handle";
|
||||
handle.innerHTML = "≡";
|
||||
handle.style.cssText = `
|
||||
width:20px;height:20px;display:flex;align-items:center;justify-content:center;
|
||||
cursor:grab;border-radius:4px;background:#f2f3f5;color:#555;user-select:none;
|
||||
`;
|
||||
|
||||
// 2️⃣ Build provider – show only when mouse is over a block
|
||||
const provider = (ctx: Ctx) => {
|
||||
const provider = new BlockProvider({
|
||||
ctx,
|
||||
content: handle,
|
||||
getOffset: () => 8,
|
||||
});
|
||||
|
||||
return {
|
||||
update: provider.update,
|
||||
destroy: provider.destroy,
|
||||
};
|
||||
};
|
||||
|
||||
// 3️⃣ Wire provider to Milkdown
|
||||
const blockConfig = (ctx: Ctx) => {
|
||||
ctx.set(blockSpec.key, {
|
||||
view: provider(ctx),
|
||||
});
|
||||
};
|
||||
|
||||
Editor.make().config(blockConfig).use(commonmark).use(block).create();
|
||||
```
|
||||
|
||||
Drag & Drop:
|
||||
|
||||
The HTML element has `cursor:grab`. The internal `BlockService` automatically sets `draggable` and wires ProseMirror's drag-events so you can reorder blocks without extra code 👉 nice!
|
||||
|
||||
---
|
||||
|
||||
## 3. Framework Examples
|
||||
|
||||
### React
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-block"}
|
||||
|
||||
The React demo renders a `<BlockHandle/>` component, keeps drag state in hooks and feeds the root element to `BlockProvider`.
|
||||
|
||||
### Vue
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-block"}
|
||||
|
||||
Vue's `<BlockHandle>` uses `Teleport` and reactive refs exactly like the tooltip/slash examples.
|
||||
|
||||
---
|
||||
|
||||
## 4. Real-world Feature – Crepe Block Handle
|
||||
|
||||
Crepe brings all the pieces together to create a **block edit** experience that combines a drag handle **and** a plus-button to open the slash menu:
|
||||
|
||||
```text
|
||||
packages/crepe/src/feature/block-edit/handle/
|
||||
```
|
||||
|
||||
Things worth exploring:
|
||||
|
||||
1. **Dynamic placement** via `getPlacement` (centred vs top-aligned depending on node height).
|
||||
2. Filtering nodes with `blockConfig.filterNodes` so handles do not appear inside tables / math / blockquotes.
|
||||
3. Programmatically showing the _slash menu_ after pressing the "+" button.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary & Next Steps
|
||||
|
||||
`@milkdown/plugin-block` is the Swiss-army knife for any block-level UI: drag handles, add-buttons, side toolbars…
|
||||
Combine it with tooltip/slash to build sophisticated editors.
|
||||
|
||||
Hack on the examples, tweak positioning callbacks, and ship your own block goodies 🚀.
|
||||
159
milkdown-docs/plugin/example-iframe-plugin.md
Normal file
159
milkdown-docs/plugin/example-iframe-plugin.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Example: Iframe Plugin
|
||||
|
||||
This guide demonstrates how to create a custom iframe syntax plugin for Milkdown. This plugin allows you to embed iframes directly in your markdown content using a simple directive syntax.
|
||||
|
||||
## Overview
|
||||
|
||||
---
|
||||
|
||||
The iframe plugin enables you to embed external web content using the following syntax:
|
||||
|
||||
```markdown
|
||||
::iframe{src="https://example.com"}
|
||||
```
|
||||
|
||||
This will render as an embedded iframe in your document.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
---
|
||||
|
||||
To create a custom syntax plugin in Milkdown, we need to implement five key components:
|
||||
|
||||
1. **Remark Plugin**: Parse the custom syntax
|
||||
2. **Schema Definition**: Define the node structure
|
||||
3. **Parser**: Convert markdown to ProseMirror nodes
|
||||
4. **Serializer**: Convert ProseMirror nodes back to markdown
|
||||
5. **Input Rules**: Handle user input
|
||||
|
||||
Let's implement each component:
|
||||
|
||||
## 1. Remark Plugin
|
||||
|
||||
---
|
||||
|
||||
First, we use the `remark-directive` plugin to support our custom syntax. This plugin allows us to define custom directives in markdown.
|
||||
|
||||
```typescript
|
||||
import directive from "remark-directive";
|
||||
import { $remark } from "@milkdown/kit/utils";
|
||||
|
||||
const remarkDirective = $remark("remarkDirective", () => directive);
|
||||
```
|
||||
|
||||
## 2. Schema Definition
|
||||
|
||||
---
|
||||
|
||||
Next, we define the schema for our iframe node. The schema specifies how the node behaves and appears in the editor.
|
||||
|
||||
```typescript
|
||||
import { $node } from "@milkdown/kit/utils";
|
||||
import { Node } from "@milkdown/kit/prose/model";
|
||||
|
||||
const iframeNode = $node("iframe", () => ({
|
||||
group: "block", // Block-level node
|
||||
atom: true, // Cannot be split
|
||||
isolating: true, // Cannot be merged with adjacent nodes
|
||||
marks: "", // No marks allowed
|
||||
attrs: {
|
||||
src: { default: null }, // URL attribute
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "iframe",
|
||||
getAttrs: (dom) => ({
|
||||
src: (dom as HTMLElement).getAttribute("src"),
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (node: Node) => [
|
||||
"iframe",
|
||||
{ ...node.attrs, contenteditable: false }, // Prevent editing iframe content
|
||||
0,
|
||||
],
|
||||
}));
|
||||
```
|
||||
|
||||
## 3. Parser
|
||||
|
||||
---
|
||||
|
||||
The parser converts our markdown syntax into ProseMirror nodes. It looks for the `leafDirective` type with the name "iframe".
|
||||
|
||||
```typescript
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === 'leafDirective' && node.name === 'iframe',
|
||||
runner: (state, node, type) => {
|
||||
state.addNode(type, { src: (node.attributes as { src: string }).src });
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
## 4. Serializer
|
||||
|
||||
---
|
||||
|
||||
The serializer converts ProseMirror nodes back to markdown format.
|
||||
|
||||
```typescript
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === 'iframe',
|
||||
runner: (state, node) => {
|
||||
state.addNode('leafDirective', undefined, undefined, {
|
||||
name: 'iframe',
|
||||
attributes: { src: node.attrs.src },
|
||||
});
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
## 5. Input Rules
|
||||
|
||||
---
|
||||
|
||||
Input rules handle user typing and convert the syntax into an iframe node.
|
||||
|
||||
```typescript
|
||||
import { InputRule } from "@milkdown/kit/prose";
|
||||
import { $inputRule } from "@milkdown/kit/utils";
|
||||
|
||||
const iframeInputRule = $inputRule(
|
||||
() =>
|
||||
new InputRule(
|
||||
/::iframe\{src\="(?<src>[^"]+)?"?\}/,
|
||||
(state, match, start, end) => {
|
||||
const [okay, src = ""] = match;
|
||||
const { tr } = state;
|
||||
if (okay) {
|
||||
tr.replaceWith(start - 1, end, iframeNode.type().create({ src }));
|
||||
}
|
||||
return tr;
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
---
|
||||
|
||||
To use the iframe plugin, add it to your Milkdown editor configuration:
|
||||
|
||||
```typescript
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
||||
|
||||
Editor.make()
|
||||
.use([remarkDirective, iframeNode, iframeInputRule])
|
||||
.use(commonmark)
|
||||
.create();
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
---
|
||||
|
||||
Here's a complete example of the iframe plugin in action:
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vanilla-iframe-syntax"}
|
||||
189
milkdown-docs/plugin/example-marker-plugin.md
Normal file
189
milkdown-docs/plugin/example-marker-plugin.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Example: Marker Plugin
|
||||
|
||||
This guide demonstrates how to create a custom marker syntax plugin for Milkdown. This plugin allows you to mark text with custom colors using a simple markdown syntax.
|
||||
|
||||
## Overview
|
||||
|
||||
---
|
||||
|
||||
The marker plugin enables you to mark text using the following syntax:
|
||||
|
||||
```markdown
|
||||
==marked text==
|
||||
=={#EE4B2B}marked text with color==
|
||||
```
|
||||
|
||||
This will render as marked text in your document, with the option to specify custom colors.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
---
|
||||
|
||||
To create a custom marker syntax plugin in Milkdown, we need to implement several components:
|
||||
|
||||
1. **Remark Plugin**: Parse the custom syntax
|
||||
2. **Schema Definition**: Define the mark structure
|
||||
3. **Parser**: Convert markdown to ProseMirror marks
|
||||
4. **Serializer**: Convert ProseMirror marks back to markdown
|
||||
5. **Input Rules**: Handle user input
|
||||
6. **Color Picker**: Add UI for color selection
|
||||
|
||||
Let's implement each component:
|
||||
|
||||
## 1. Remark Plugin
|
||||
|
||||
---
|
||||
|
||||
First, we create a remark plugin to handle our custom marker syntax:
|
||||
|
||||
> ⚠️ The real implementation is more complex, but we simplify it for the sake of the example.
|
||||
> Under the hood, you'll need to write a [micromark extension](https://github.com/micromark/micromark) to make it works correctly.
|
||||
|
||||
```typescript
|
||||
import { $remark } from "@milkdown/kit/utils";
|
||||
|
||||
const remarkMarkColor = () => {
|
||||
return (tree: any) => {
|
||||
visit(tree, "text", (node: any, index: number, parent: any) => {
|
||||
const match = node.value.match(/==(?:{#([^}]+)})?([^=]+)==/);
|
||||
if (match) {
|
||||
const [_, color, text] = match;
|
||||
const mark = {
|
||||
type: "mark",
|
||||
data: { color },
|
||||
children: [{ type: "text", value: text }],
|
||||
};
|
||||
parent.children.splice(index, 1, mark);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const milkdownMarkColorPlugin = $remark("markColor", () => remarkMarkColor);
|
||||
```
|
||||
|
||||
## 2. Schema Definition
|
||||
|
||||
---
|
||||
|
||||
Next, we define the schema for our marker:
|
||||
|
||||
```typescript
|
||||
import { $markSchema } from "@milkdown/kit/utils";
|
||||
import { Mark } from "mdast";
|
||||
|
||||
export const DEFAULT_COLOR = "#ffff00";
|
||||
|
||||
export const markSchema = $markSchema("mark", () => ({
|
||||
attrs: {
|
||||
color: {
|
||||
default: DEFAULT_COLOR,
|
||||
validate: "string",
|
||||
},
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: "mark",
|
||||
getAttrs: (node: HTMLElement) => ({
|
||||
color: node.style.backgroundColor,
|
||||
}),
|
||||
},
|
||||
],
|
||||
toDOM: (mark) => ["mark", { style: `background-color: ${mark.attrs.color}` }],
|
||||
parseMarkdown: {
|
||||
match: (node) => node.type === "mark",
|
||||
runner: (state, node, markType) => {
|
||||
const color = (node as Mark).data?.color;
|
||||
state.openMark(markType, { color });
|
||||
state.next(node.children);
|
||||
state.closeMark(markType);
|
||||
},
|
||||
},
|
||||
toMarkdown: {
|
||||
match: (node) => node.type.name === "mark",
|
||||
runner: (state, mark) => {
|
||||
let color = mark.attrs.color;
|
||||
if (color?.toLowerCase() === DEFAULT_COLOR.toLowerCase()) {
|
||||
color = undefined;
|
||||
}
|
||||
state.withMark(mark, "mark", undefined, {
|
||||
data: { color },
|
||||
});
|
||||
},
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
## 3. Input Rules
|
||||
|
||||
---
|
||||
|
||||
We add input rules to handle user typing:
|
||||
|
||||
```typescript
|
||||
import { $inputRule } from "@milkdown/kit/utils";
|
||||
import { InputRule } from "@milkdown/kit/prose";
|
||||
|
||||
const markInputRule = $inputRule(
|
||||
() =>
|
||||
new InputRule(/==(?:{#([^}]+)})?([^=]+)==/, (state, match, start, end) => {
|
||||
const [okay, color, text] = match;
|
||||
const { tr } = state;
|
||||
if (okay) {
|
||||
tr.addMark(
|
||||
start,
|
||||
end,
|
||||
markSchema.type().create({ color: color || DEFAULT_COLOR }),
|
||||
);
|
||||
}
|
||||
return tr;
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
## 4. Color Picker Tooltip
|
||||
|
||||
---
|
||||
|
||||
To enhance the user experience, we add a color picker tooltip:
|
||||
|
||||
```typescript
|
||||
export const colorPickerTooltip = tooltipFactory("color-picker");
|
||||
|
||||
class TooltipPluginView {
|
||||
// ... implementation
|
||||
}
|
||||
|
||||
export const colorPickerTooltipConfig = (ctx: Ctx) => {
|
||||
ctx.set(colorPickerTooltip.key, {
|
||||
view: () => new TooltipPluginView(ctx),
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
---
|
||||
|
||||
To use the marker plugin, add it to your Milkdown editor configuration:
|
||||
|
||||
```typescript
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
||||
|
||||
Editor.make()
|
||||
.use(milkdownMarkColorPlugin)
|
||||
.use(markSchema)
|
||||
.use(markInputRule)
|
||||
.use(colorPickerTooltip)
|
||||
.use(commonmark)
|
||||
.create();
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
---
|
||||
|
||||
Here's a complete example of the marker plugin in action:
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vanilla-highlight-syntax"}
|
||||
137
milkdown-docs/plugin/example-slash-plugin.md
Normal file
137
milkdown-docs/plugin/example-slash-plugin.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Example: Slash Plugin
|
||||
|
||||
After reading the tooltip guide you already know how Milkdown separates **positioning logic** (provider) from **editor wiring** (ctx slices produced by a factory).
|
||||
The `@milkdown/plugin-slash` package applies exactly the same idea but focuses on _command palettes_ triggered by a character – familiar to `/` menus in modern editors.
|
||||
|
||||
This document shows you how to:
|
||||
|
||||
- Understand what the slash plugin gives you out-of-the-box.
|
||||
- Build a **vanilla TypeScript** implementation of a basic `/` menu.
|
||||
- Use the slash provider with **React** and **Vue**.
|
||||
- Explore a full-blown menu feature that ships inside Milkdown's Crepe UI.
|
||||
|
||||
---
|
||||
|
||||
## 1. Anatomy of a Slash Plugin
|
||||
|
||||
`@milkdown/plugin-slash` exports two utilities:
|
||||
|
||||
1. **`SlashProvider`** – Measures the caret position and manages show / hide of your menu.
|
||||
2. **`slashFactory(id)`** – Generates a ctx slice & ProseMirror plugin pair that plugs the provider into the editor.
|
||||
|
||||
```ts
|
||||
import { slashFactory } from "@milkdown/plugin-slash";
|
||||
|
||||
export const [mySlashSpec, mySlashPlugin] = slashFactory("my");
|
||||
```
|
||||
|
||||
Just like the tooltip factory:
|
||||
|
||||
- `mySlashSpec` is where you put a `PluginSpec` (what ProseMirror needs).
|
||||
- `mySlashPlugin` turns that spec into a runtime plugin.
|
||||
|
||||
---
|
||||
|
||||
## 2. A Minimal Vanilla `/` Menu
|
||||
|
||||
Below we create a small menu that suggests two commands whenever the user types `/`.
|
||||
|
||||
```ts
|
||||
import { SlashProvider, slashFactory } from "@milkdown/plugin-slash";
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
||||
|
||||
// DOM content of the menu – plain HTML for the demo
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "slash-menu";
|
||||
menu.style.cssText = `
|
||||
position:absolute;padding:4px 0;background:white;border:1px solid #eee;
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.15);border-radius:6px;font-size:14px;
|
||||
`;
|
||||
menu.innerHTML = `<ul style="margin:0;padding:0;list-style:none">
|
||||
<li data-cmd="h1" style="padding:4px 12px;cursor:pointer">Heading 1</li>
|
||||
<li data-cmd="bullet" style="padding:4px 12px;cursor:pointer">Bullet List</li>
|
||||
</ul>`;
|
||||
|
||||
// Click handler – replace with real commands
|
||||
menu.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const cmd = target.dataset.cmd;
|
||||
alert(`Run command: ${cmd}`);
|
||||
});
|
||||
|
||||
// Provider positions & shows above DOM element
|
||||
const provider = new SlashProvider({
|
||||
content: menu,
|
||||
// show the menu when the last character before caret is '/'
|
||||
shouldShow(view) {
|
||||
return provider.getContent(view)?.endsWith("/") ?? false;
|
||||
},
|
||||
offset: 8,
|
||||
});
|
||||
|
||||
const slash = slashFactory("demo");
|
||||
const slashConfig = (ctx: Ctx) => {
|
||||
ctx.set(slash.key, {
|
||||
view: () => ({
|
||||
update: provider.update,
|
||||
destroy: provider.destroy,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
Editor.make().config(slashConfig).use(commonmark).use(slash).create();
|
||||
```
|
||||
|
||||
Key takeaways:
|
||||
|
||||
- `SlashProvider` has a helper `getContent(view)` to fetch text before the caret – handy for filtering.
|
||||
- You decide **when to show** the menu via the `shouldShow` callback (default: when last char is `/`).
|
||||
- The provider only manipulates **position + visibility**; rendering & commands are completely yours.
|
||||
|
||||
---
|
||||
|
||||
## 3. Framework Examples
|
||||
|
||||
### React
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-slash"}
|
||||
|
||||
Highlights:
|
||||
|
||||
1. A `<SlashMenu/>` React component renders the list.
|
||||
2. The component root is passed to `SlashProvider` (just like the tooltip demo).
|
||||
3. React hooks manage internal focus & keyboard navigation.
|
||||
|
||||
### Vue
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-slash"}
|
||||
|
||||
The Vue version uses `Teleport` to append the menu to `document.body` and `ref` / `watch` for reactivity.
|
||||
|
||||
---
|
||||
|
||||
## 4. Real-world Feature – Crepe Block Menu
|
||||
|
||||
Milkdown's **Crepe** UI implements an extensible block-level menu on top of the slash plugin. You'll find the source code at:
|
||||
|
||||
```text
|
||||
packages/crepe/src/feature/block-edit/menu/
|
||||
```
|
||||
|
||||
Notable patterns to look for:
|
||||
|
||||
- **Context slices** (`menu` / `menuAPI`) to expose imperative `show` & `hide` methods.
|
||||
- Filtering commands based on the current text after `/`.
|
||||
- Preventing the menu inside `code` blocks or lists.
|
||||
|
||||
Studying this folder is a great next step once you master the basics.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary & Next Steps
|
||||
|
||||
- `@milkdown/plugin-slash` gives you caret detection + positioning – nothing else.
|
||||
- UI, behaviour, and commands are fully customisable.
|
||||
|
||||
Fork one of the examples above, add your own commands, and you'll have a modern `/` command palette in minutes ✨.
|
||||
140
milkdown-docs/plugin/example-tooltip-plugin.md
Normal file
140
milkdown-docs/plugin/example-tooltip-plugin.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# Example: Tooltip Plugin
|
||||
|
||||
This guide walks you through creating and using **tooltip-based plugins** in Milkdown.
|
||||
You will learn how the low-level `@milkdown/plugin-tooltip` works and how to build richer experiences on top of it in **vanilla TypeScript**, **React**, and **Vue**.
|
||||
|
||||
> **TL;DR** – A tooltip in Milkdown is nothing more than a ProseMirror plugin created by `tooltipFactory(id)`.
|
||||
> It receives position information from the editor and renders any DOM of your choice.
|
||||
> Everything else (buttons, inputs, styling, framework bindings) can be composed on top of that.
|
||||
|
||||
## 1. Anatomy of a Tooltip
|
||||
|
||||
---
|
||||
|
||||
At its core the tooltip plugin exported from `@milkdown/plugin-tooltip` contains two helpers:
|
||||
|
||||
1. **`TooltipProvider`** – An utility class powered by [floating-ui](https://floating-ui.com/) to calculate the tooltip position.
|
||||
2. **`tooltipFactory(id)`** – A factory that returns a pair of Milkdown plugin slices which wire the provider into the editor.
|
||||
|
||||
The factory is extremely small (≈40 lines):
|
||||
|
||||
```ts
|
||||
import { tooltipFactory } from "@milkdown/plugin-tooltip";
|
||||
|
||||
// Create a tooltip identified by the string "my".
|
||||
export const [myTooltipSpec, myTooltipPlugin] = tooltipFactory("my");
|
||||
```
|
||||
|
||||
The first element (`myTooltipSpec`) is a **ctx slice** that stores a `PluginSpec`, while the second one (`myTooltipPlugin`) is the real ProseMirror plugin which consumes that spec.
|
||||
|
||||
## 2. A Minimal Vanilla Tooltip
|
||||
|
||||
---
|
||||
|
||||
Below is the complete code for a tooltip that shows the **length of the current selection**.
|
||||
|
||||
```ts
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
||||
import { TooltipProvider, tooltipFactory } from "@milkdown/plugin-tooltip";
|
||||
|
||||
// 1) Prepare DOM that we will mount into the page.
|
||||
const el = document.createElement("div");
|
||||
el.className = "selection-length";
|
||||
el.style.cssText = `
|
||||
pointer-events:none;
|
||||
background:#333;color:#fff;padding:2px 6px;border-radius:4px;font-size:12px;
|
||||
`;
|
||||
|
||||
// 2) Build a provider which updates the content.
|
||||
const provider = new TooltipProvider({
|
||||
content: el,
|
||||
shouldShow: (view) => !!view.state.selection.content().size,
|
||||
});
|
||||
|
||||
// 3) Bridge provider & editor.
|
||||
const tooltip = tooltipFactory("sel-length");
|
||||
const tooltipConfig = (ctx: Ctx) => {
|
||||
ctx.set(selectionTooltipSpec.key, {
|
||||
view: () => ({
|
||||
update: provider.update,
|
||||
destroy: provider.destroy,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
Editor.make().config(tooltipConfig).use(commonmark).use(tooltip).create();
|
||||
```
|
||||
|
||||
Key points:
|
||||
|
||||
- We **create** any DOM element we like (`el`).
|
||||
- `TooltipProvider` tracks the editor position and moves the element.
|
||||
- `tooltipFactory` wraps the provider into a pluggable slice.
|
||||
|
||||
## 3. Framework Examples
|
||||
|
||||
---
|
||||
|
||||
Sometimes building UI is easier in your favourite framework.
|
||||
Because the tooltip provider only deals with **DOM elements**, you can freely render React, Vue or Svelte components and pass their root node to the provider.
|
||||
|
||||
### React
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/react-tooltip"}
|
||||
|
||||
The React example shows how to:
|
||||
|
||||
1. Create a React component (`<SelectionTooltip/>`).
|
||||
2. Render it into a portal and give the root HTML element to `TooltipProvider`.
|
||||
3. Re-use React state/hooks while Milkdown takes care of positioning.
|
||||
|
||||
### Vue
|
||||
|
||||
::iframe{src="https://stackblitz.com/github/Milkdown/examples/tree/main/vue-tooltip"}
|
||||
|
||||
The Vue example follows the same pattern with `defineComponent` and `teleport`.
|
||||
|
||||
## 4. Real-world Examples
|
||||
|
||||
---
|
||||
|
||||
### 4-1. Link Tooltip (_@milkdown/component/link-tooltip_)
|
||||
|
||||
The [link tooltip](https://github.com/Milkdown/milkdown/tree/main/packages/components/src/link-tooltip) demonstrates how to:
|
||||
|
||||
- Maintain UI **state** (`preview` vs `edit`) in ctx slices.
|
||||
- Communicate with the editor through an **API slice** (add / edit / remove links).
|
||||
- Render framework-agnostic UI inside a tooltip provider.
|
||||
|
||||
Have a look at the files below to see those techniques in action:
|
||||
|
||||
```text
|
||||
packages/components/src/link-tooltip/
|
||||
├── slices.ts # state & API slices
|
||||
├── tooltips.ts # preview & edit providers
|
||||
└── component.tsx # (framework examples)
|
||||
```
|
||||
|
||||
### 4-2. Toolbar Feature (_@milkdown/crepe/feature/toolbar_)
|
||||
|
||||
The toolbar in the [crepe](https://github.com/Milkdown/milkdown/tree/main/packages/crepe) package pushes the idea further by:
|
||||
|
||||
- Using multiple tooltip instances (one per button group).
|
||||
- Rendering the UI with Vue _inside_ the provider.
|
||||
- Sharing configuration via ctx slices so that every button is extensible by third-party plugins.
|
||||
|
||||
You can browse the implementation starting from
|
||||
|
||||
```text
|
||||
packages/crepe/src/feature/toolbar/component.tsx
|
||||
```
|
||||
|
||||
## 5. Summary & Next Steps
|
||||
|
||||
---
|
||||
|
||||
- `@milkdown/plugin-tooltip` offers **just enough** abstraction: positioning & lifecycle.
|
||||
- Everything else – **state, styling, framework integration** – is totally up to you.
|
||||
|
||||
Try to customise one of the examples above, then ship your own tooltip-powered features 🤟.
|
||||
174
milkdown-docs/plugin/plugins-101.md
Normal file
174
milkdown-docs/plugin/plugins-101.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Plugins 101
|
||||
|
||||
In this section we will show you the basic information of the plugin.
|
||||
In most cases, you will not need to write plugins without helpers.
|
||||
But it can help you understand the plugin system and what happens under the hood.
|
||||
|
||||
## Structure Overview
|
||||
|
||||
Generally speaking, a plugin will have following structure:
|
||||
|
||||
```typescript
|
||||
import { MilkdownPlugin } from "@milkdown/kit/ctx";
|
||||
|
||||
const myPlugin: MilkdownPlugin = (ctx) => {
|
||||
// #1 prepare plugin
|
||||
return async () => {
|
||||
// #2 run plugin
|
||||
return async () => {
|
||||
// #3 clean up plugin
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
Each plugin is composed by three parts:
|
||||
|
||||
1. _Prepare_: this part will be executed when plugin is registered in milkdown by `.use` method.
|
||||
2. _Run_: this part will be executed when plugin is actually loaded.
|
||||
3. _Post_: this part will be executed when plugin is removed by `.remove` method or editor is destroyed.
|
||||
|
||||
## Timer
|
||||
|
||||
Timer can be used to decide when to load the current plugin and how current plugin can influence other plugin's loading status.
|
||||
|
||||
You can use `ctx.wait` to wait a timer to finish.
|
||||
|
||||
```typescript
|
||||
import { MilkdownPlugin, Complete } from "@milkdown/kit/core";
|
||||
|
||||
const myPlugin: MilkdownPlugin = (ctx) => {
|
||||
return async () => {
|
||||
const start = Date.now();
|
||||
|
||||
await ctx.wait(Complete);
|
||||
|
||||
const end = Date.now();
|
||||
|
||||
console.log("Milkdown load duration: ", end - start);
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
You can also create your own timer and influence other plugins load time.
|
||||
For example, let's create a plugin that will fetch markdown content from remote server as editor's default value.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MilkdownPlugin,
|
||||
editorStateTimerCtx,
|
||||
defaultValueCtx,
|
||||
createTimer,
|
||||
} from "@milkdown/kit/core";
|
||||
|
||||
const RemoteTimer = createTimer("RemoteTimer");
|
||||
|
||||
const remotePlugin: MilkdownPlugin = (ctx) => {
|
||||
// register timer
|
||||
ctx.record(RemoteTimer);
|
||||
|
||||
return async () => {
|
||||
// the editorState plugin will wait for this timer to finish before initialize editor state.
|
||||
ctx.update(editorStateTimerCtx, (timers) => timers.concat(RemoteTimer));
|
||||
|
||||
const defaultMarkdown = await fetchMarkdownAPI();
|
||||
ctx.set(defaultValueCtx, defaultMarkdown);
|
||||
|
||||
// mark timer as complete
|
||||
ctx.done(RemoteTimer);
|
||||
|
||||
return async () => {
|
||||
await SomeAPI();
|
||||
|
||||
// remove timer when plugin is removed
|
||||
ctx.clearTimer(RemoteTimer);
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
It has following steps:
|
||||
|
||||
1. We use `createTimer` to create a timer, and use `pre.record` to register it into milkdown.
|
||||
2. We update `editorStateTimerCtx` to tell the internal `editorState` plugin that before initialize editor state, it should wait our remote fetch process finished.
|
||||
3. After we get value from `fetchMarkdownAPI`, we set it as `defaultValue` and use `ctx.done` to mark a timer as complete.
|
||||
|
||||
## Ctx
|
||||
|
||||
We have used `ctx` several times in the above example, now we can try to understand what it is.
|
||||
|
||||
Ctx is a data container which is shared in the entire editor instance. It's composed by a lot of slices. Every `slice` has a unique key and a value. You can change the value of a slice by `ctx.set` and `ctx.update`. And you can get the value of a slice by `ctx.get` with the slice key or name. Last but not least, you can remove a slice by `post.remove`.
|
||||
|
||||
```typescript
|
||||
import { MilkdownPlugin, createSlice } from "@milkdown/kit/ctx";
|
||||
|
||||
const counterCtx = createSlice(0, "counter");
|
||||
|
||||
const counterPlugin: MilkdownPlugin = (ctx) => {
|
||||
ctx.inject(counterCtx);
|
||||
|
||||
return () => {
|
||||
// count is 0
|
||||
const count0 = ctx.get(counterCtx);
|
||||
|
||||
// set count to 1
|
||||
ctx.set(counterCtx, 1);
|
||||
|
||||
// now count is 1
|
||||
const count1 = ctx.get(counterCtx);
|
||||
|
||||
// set count to n + 2
|
||||
ctx.update(counterCtx, (prev) => prev + 2);
|
||||
|
||||
// now count is 3
|
||||
const count2 = ctx.get(counterCtx);
|
||||
// we can also get value by the slice name
|
||||
const count3 = ctx.get("counter");
|
||||
|
||||
return () => {
|
||||
// remove the slice
|
||||
ctx.remove(counterCtx);
|
||||
};
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
We can use `createSlice` to create a ctx, and use `pre.inject` to inject the ctx into the editor.
|
||||
|
||||
And when plugin processing, `ctx.get` can get the value of a ctx, `ctx.set` can set the value of a ctx, and `ctx.update` can update a ctx using callback function.
|
||||
|
||||
So, we can use `ctx` combine with `timer` to decide when should a plugin be processed.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
MilkdownPlugin,
|
||||
SchemaReady,
|
||||
Timer,
|
||||
createSlice,
|
||||
} from "@milkdown/kit/core";
|
||||
|
||||
const examplePluginTimersCtx = createSlice<Timer[]>([], "example-timer");
|
||||
|
||||
const examplePlugin: MilkdownPlugin = (ctx) => {
|
||||
ctx.inject(examplePluginTimersCtx, [SchemaReady]);
|
||||
return async () => {
|
||||
await Promise.all(
|
||||
ctx.get(examplePluginTimersCtx).map((timer) => ctx.wait(timer)),
|
||||
);
|
||||
// or we can use a simplified syntax sugar
|
||||
await ctx.waitTimers(examplePluginTimersCtx);
|
||||
|
||||
// do something
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
With this pattern, if other plugins want to delay the process of `examplePlugin`, all they need to do is just add a timer into `examplePluginTimersCtx` with `ctx.update`.
|
||||
|
||||
## Summary
|
||||
|
||||
Now let's go back to the plugin structure. Since we have the knowledge of `timer` and `ctx`, we can understand what we should do in each part of a plugin.
|
||||
|
||||
1. In `prepare` stage of the plugin, we can use `ctx.record` to register a timer, and use `ctx.inject` to inject a slice.
|
||||
2. In `run` stage of the plugin, we can use `ctx.wait` to wait a timer to finish, and use `ctx.get` to get the value of a slice. We can also change values of slices by `ctx.set` and `ctx.update`. And we can use `ctx.done` to mark a timer as complete.
|
||||
3. In `post` stage of the plugin, we can use `ctx.clearTimer` to clear a timer, and use `ctx.remove` to remove a slice.
|
||||
28
milkdown-docs/plugin/using-components.md
Normal file
28
milkdown-docs/plugin/using-components.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Using Components
|
||||
|
||||
Components are features work out of the box that built on top of plugins.
|
||||
Each component is a separate module. You can use them by importing them from `@milkdown/kit/component/*`.
|
||||
All components can be used just like plugins.
|
||||
|
||||
```ts
|
||||
import { imageBlock } from "@milkdown/kit/component/image-block";
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
|
||||
Editor.make().use(/* some other plugins */).use(imageBlock).create();
|
||||
```
|
||||
|
||||
Components are designed to be headless, which means they are not opinionated about the UI.
|
||||
You can use them to build your own editor UI. Components are built by web components and can be used in any framework.
|
||||
|
||||
---
|
||||
|
||||
# List of Components
|
||||
|
||||
| Name | Description |
|
||||
| ------------------------------------------------ | ---------------------------------------------------------- |
|
||||
| [Code Block](/docs/api/component-code-block) | Render code by [Codemirror](https://codemirror.net/) |
|
||||
| [Image Block](/docs/api/component-image-block) | Render an image as a block |
|
||||
| [Image Inline](/docs/api/component-image-inline) | Provide placeholder and uploader features for inline image |
|
||||
| [Link Tooltip](/docs/api/component-link-tooltip) | Provide edit and preview feature for link |
|
||||
| [List Item](/docs/api/component-list-item-block) | Renderers bullet, ordered and task list by custom renderer |
|
||||
| [Table Block](/docs/api/component-table-block) | Render table and provides table editing features |
|
||||
87
milkdown-docs/plugin/using-plugins.md
Normal file
87
milkdown-docs/plugin/using-plugins.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Using Plugins
|
||||
|
||||
All features in milkdown are provided by plugin.
|
||||
Such as syntax, components, etc.
|
||||
Now we can try more plugins:
|
||||
|
||||
```typescript
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
import { slash } from "@milkdown/kit/plugin/slash";
|
||||
import { tooltip } from "@milkdown/kit/plugin/tooltip";
|
||||
import { commonmark } from "@milkdown/kit/preset/commonmark";
|
||||
|
||||
Editor.make().use(commonmark).use(tooltip).use(slash).create();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Toggling Plugins
|
||||
|
||||
You can also toggle plugins programmatically:
|
||||
|
||||
```typescript
|
||||
import { Editor } from "@milkdown/kit/core";
|
||||
import { someMilkdownPlugin } from "some-milkdown-plugin";
|
||||
|
||||
const editor = await Editor.config(configForPlugin)
|
||||
.use(someMilkdownPlugin)
|
||||
.create();
|
||||
|
||||
// remove plugin
|
||||
await editor.remove(someMilkdownPlugin);
|
||||
|
||||
// remove config
|
||||
editor.removeConfig(configForPlugin);
|
||||
|
||||
// add another plugin
|
||||
editor.use(anotherMilkdownPlugin);
|
||||
|
||||
// Recreate the editor to apply changes.
|
||||
await editor.create();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Official Plugins
|
||||
|
||||
Milkdown provides the following official plugins:
|
||||
|
||||
### Plugins provided by `@milkdown/kit`:
|
||||
|
||||
> 🙋♀️Why not all plugins are available in `@milkdown/kit`?
|
||||
>
|
||||
> `@milkdown/kit` is a collection of plugins that are commonly used in the editor.
|
||||
> If you want to use a plugin that is not in `@milkdown/kit`, you can install it separately.
|
||||
> The plugins in `@milkdown/kit` are also stable and well-tested.
|
||||
|
||||
| Package Name | Description |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| [@milkdown/kit/preset/commonmark](/docs/api/preset-commonmark) | Add [commonmark](https://commonmark.org/) syntax support. |
|
||||
| [@milkdown/kit/preset/gfm](/docs/api/preset-gfm) | Add [gfm](https://github.github.com/gfm/) syntax support. |
|
||||
| [@milkdown/kit/plugin/history](/docs/api/plugin-history) | Add undo & redo support. |
|
||||
| [@milkdown/kit/plugin/clipboard](/docs/api/plugin-clipboard) | Add markdown copy & paste support. |
|
||||
| [@milkdown/kit/plugin/cursor](/docs/api/plugin-cursor) | Add drop & gap cursor. |
|
||||
| [@milkdown/kit/plugin/listener](/docs/api/plugin-listener) | Add listener support. |
|
||||
| [@milkdown/kit/plugin/indent](/docs/api/plugin-indent) | Add tab indent support. |
|
||||
| [@milkdown/kit/plugin/upload](/docs/api/plugin-upload) | Add drop and upload support. |
|
||||
| [@milkdown/kit/plugin/block](/docs/api/plugin-block) | Add a drag handle for every block node. |
|
||||
| [@milkdown/kit/plugin/tooltip](/docs/api/plugin-tooltip) | Add universal tooltip support. |
|
||||
| [@milkdown/kit/plugin/slash](/docs/api/plugin-slash) | Add universal slash commands support. |
|
||||
|
||||
### Other Plugins:
|
||||
|
||||
- [@milkdown/plugin-collab](/docs/api/plugin-collab)
|
||||
|
||||
Add collaborative editing support, powered by [yjs](https://docs.yjs.dev/).
|
||||
|
||||
- [@milkdown/plugin-prism](/docs/api/plugin-prism)
|
||||
|
||||
Add [prism](https://prismjs.com/) support for code block highlight.
|
||||
|
||||
- [@milkdown/plugin-emoji](/docs/api/plugin-emoji)
|
||||
|
||||
Add emoji shortcut support (something like `:+1:`), and use [twemoji](https://twemoji.twitter.com/) to display emoji.
|
||||
|
||||
## Community plugins
|
||||
|
||||
Check out [awesome-milkdown](https://github.com/Milkdown/awesome-milkdown) to find community plugins. You can also submit a PR to list your plugins there.
|
||||
Reference in New Issue
Block a user