@grandaniel/vue-markdown-editor 1.1.1 → 1.1.3-dev.25ab77b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +363 -5
- package/dist/components/MarkdownEditor/Composable/activeEditorStore.d.ts +7 -0
- package/dist/components/MarkdownEditor/Composable/useMarkdownEditor.d.ts +1 -0
- package/dist/components/MarkdownEditor/Composable/useMarkdownProcessor.d.ts +1 -0
- package/dist/components/MarkdownEditor/ContextMenu/MarkdownEditorImageContextMenu.vue.d.ts +4 -2
- package/dist/components/MarkdownEditor/MarkdownEditor.vue.d.ts +8 -0
- package/dist/components/MarkdownEditor/Modules/MarkdownModuleHeadline1.vue.d.ts +1 -0
- package/dist/components/MarkdownEditor/Modules/MarkdownModuleHeadline2.vue.d.ts +1 -0
- package/dist/components/MarkdownEditor/Modules/MarkdownModuleHeadline3.vue.d.ts +1 -0
- package/dist/components/MarkdownEditor/Modules/MarkdownModuleList.vue.d.ts +1 -0
- package/dist/components/MarkdownEditor/Modules/MarkdownModuleParagraph.vue.d.ts +1 -0
- package/dist/components/MarkdownRenderer/MarkdownRenderComponentRegistry.d.ts +7 -0
- package/dist/components/MarkdownRenderer/MarkdownRenderer.vue.d.ts +13 -0
- package/dist/components/MarkdownRenderer/RenderModules/MarkdownModuleHeadline1Render.vue.d.ts +7 -0
- package/dist/components/MarkdownRenderer/RenderModules/MarkdownModuleHeadline2Render.vue.d.ts +7 -0
- package/dist/components/MarkdownRenderer/RenderModules/MarkdownModuleHeadline3Render.vue.d.ts +7 -0
- package/dist/components/MarkdownRenderer/RenderModules/MarkdownModuleImageRender.vue.d.ts +7 -0
- package/dist/components/MarkdownRenderer/RenderModules/MarkdownModuleListRender.vue.d.ts +7 -0
- package/dist/components/MarkdownRenderer/RenderModules/MarkdownModuleParagraphRender.vue.d.ts +7 -0
- package/dist/components/MarkdownRenderer/index.d.ts +2 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/vue-markdown-editor.css +1 -1
- package/dist/vue-markdown-editor.mjs +4383 -4110
- package/package.json +11 -13
package/README.md
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/img/editor-1.png">
|
|
3
|
+
<img alt="vue-markdown-editor — A block-based, Notion-like markdown editor for Vue 3" src="docs/img/editor-1.png">
|
|
4
|
+
</picture>
|
|
5
|
+
|
|
1
6
|
# @grandaniel/vue-markdown-editor
|
|
2
7
|
|
|
3
|
-
Vue 3
|
|
8
|
+
> A block-based, Notion-like Markdown editor for Vue 3. UI-first. Powered by [TipTap](https://tiptap.dev/).
|
|
9
|
+
|
|
10
|
+
**@grandaniel/vue-markdown-editor** is a rich block-editing experience where every Markdown element — headlines, paragraphs, lists, images — is its own independently editable, draggable block. Built for content-first workflows: write, reorder, and format with keyboard shortcuts and an always-visible drag handle.
|
|
11
|
+
|
|
12
|
+
Images are first-class citizens: paste an image to auto-upload, then edit its **src**, **alt text**, and **caption** in a dedicated modal. The editor serializes back to clean Markdown automatically.
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Table of Contents
|
|
17
|
+
|
|
18
|
+
- [Who uses it](#who-uses-it)
|
|
19
|
+
- [Installation](#installation)
|
|
20
|
+
- [Quick Start](#quick-start)
|
|
21
|
+
- [Keyboard Shortcuts](#keyboard-shortcuts)
|
|
22
|
+
- [Image Upload](#image-upload)
|
|
23
|
+
- [API Reference](#api-reference)
|
|
24
|
+
- [Custom Styling](#custom-styling)
|
|
25
|
+
- [Supply Chain Security](#supply-chain-security)
|
|
26
|
+
- [Contributing](#contributing)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Who uses it
|
|
32
|
+
|
|
33
|
+
| Project | How |
|
|
34
|
+
|---|---|
|
|
35
|
+
| **[heartbeat.systems](https://heartbeat.systems)** | Admin utility — content editors manage help articles, release notes, and in-app documentation through the block editor. |
|
|
36
|
+
| **[markdownstud.io](https://markdownstud.io)** | AI writing assistant — the editor serves as the primary composition surface where users draft, review, and polish AI-generated content. |
|
|
37
|
+
|
|
38
|
+
---
|
|
4
39
|
|
|
5
40
|
## Installation
|
|
6
41
|
|
|
@@ -8,16 +43,34 @@ Vue 3 markdown editor component library.
|
|
|
8
43
|
npm install @grandaniel/vue-markdown-editor
|
|
9
44
|
```
|
|
10
45
|
|
|
46
|
+
> **Peer dependency:** Vue `^3.5.0`
|
|
47
|
+
> **Node:** `>=22`
|
|
48
|
+
|
|
49
|
+
Import the component **and** the stylesheet:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { MarkdownEditor, useMarkdownEditor } from "@grandaniel/vue-markdown-editor";
|
|
53
|
+
import "@grandaniel/vue-markdown-editor/style.css";
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
11
58
|
## Quick Start
|
|
12
59
|
|
|
60
|
+
The editor is driven by a composable: `useMarkdownEditor()` creates the reactive state, and `<MarkdownEditor>` renders it.
|
|
61
|
+
|
|
13
62
|
```vue
|
|
14
63
|
<script setup lang="ts">
|
|
15
64
|
import { ref } from "vue";
|
|
16
|
-
import {
|
|
17
|
-
|
|
65
|
+
import {
|
|
66
|
+
MarkdownEditor,
|
|
67
|
+
useMarkdownEditor,
|
|
68
|
+
type MarkdownAstNode,
|
|
69
|
+
} from "@grandaniel/vue-markdown-editor";
|
|
70
|
+
import "@grandaniel/vue-markdown-editor/style.css";
|
|
18
71
|
|
|
19
|
-
const editor = useMarkdownEditor("# Hello
|
|
20
|
-
const focusedNode = ref(null);
|
|
72
|
+
const editor = useMarkdownEditor("# Hello, world!\n\nStart writing here…");
|
|
73
|
+
const focusedNode = ref<MarkdownAstNode | null>(null);
|
|
21
74
|
</script>
|
|
22
75
|
|
|
23
76
|
<template>
|
|
@@ -27,3 +80,308 @@ const focusedNode = ref(null);
|
|
|
27
80
|
/>
|
|
28
81
|
</template>
|
|
29
82
|
```
|
|
83
|
+
|
|
84
|
+
### Reading the output
|
|
85
|
+
|
|
86
|
+
The composable keeps the raw Markdown in sync automatically. Read it at any time:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
console.log(editor.markdownContent.value);
|
|
90
|
+
// "# Hello, world!\n\nStart writing here…"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
You can also **programmatically set** the content:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
editor.markdownContent.value = "## New heading\n\nFresh content.";
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## Keyboard Shortcuts
|
|
102
|
+
|
|
103
|
+
| Key | Action |
|
|
104
|
+
|---|---|
|
|
105
|
+
| <kbd>↑</kbd> / <kbd>↓</kbd> | Move focus between blocks |
|
|
106
|
+
| <kbd>Enter</kbd> | Split current block → insert new paragraph below |
|
|
107
|
+
| <kbd>Backspace</kbd> (empty block) | Delete the block, focus moves up |
|
|
108
|
+
| <kbd>Delete</kbd> (empty block) | Delete the block, focus stays at same index |
|
|
109
|
+
| Click blank area | Append a new empty paragraph at the bottom |
|
|
110
|
+
|
|
111
|
+
### Auto type‑detection
|
|
112
|
+
|
|
113
|
+
Type `# `, `## `, or `### ` at the start of a paragraph and the block auto‑converts to the matching heading level.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Image Upload
|
|
118
|
+
|
|
119
|
+
Images are first-class blocks with **src**, **alt text**, and **caption** fields. Right‑click any image → **Edit Attributes** to open the editing modal.
|
|
120
|
+
|
|
121
|
+
### Paste‑to‑upload
|
|
122
|
+
|
|
123
|
+
Pass an `imageUploadFunction` prop — any pasted image (`Ctrl+V`) from the clipboard will be sent through your upload handler and inserted as a new image block:
|
|
124
|
+
|
|
125
|
+
```vue
|
|
126
|
+
<script setup lang="ts">
|
|
127
|
+
async function uploadImage(file: File): Promise<string> {
|
|
128
|
+
const formData = new FormData();
|
|
129
|
+
formData.append("image", file);
|
|
130
|
+
|
|
131
|
+
const res = await fetch("/api/upload", { method: "POST", body: formData });
|
|
132
|
+
const { url } = await res.json();
|
|
133
|
+
return url;
|
|
134
|
+
}
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<template>
|
|
138
|
+
<MarkdownEditor
|
|
139
|
+
:editor="editor"
|
|
140
|
+
:image-upload-function="uploadImage"
|
|
141
|
+
/>
|
|
142
|
+
</template>
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The serialized Markdown uses a custom block syntax for images:
|
|
146
|
+
|
|
147
|
+
```markdown
|
|
148
|
+
"""MarkdownModuleImage
|
|
149
|
+
src: https://example.com/photo.jpg
|
|
150
|
+
alt: A scenic mountain view
|
|
151
|
+
caption: Photo taken during the 2026 summit
|
|
152
|
+
"""
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
> **Note:** `imageUploadFunction` is optional. Without it, pasted images from the clipboard are ignored.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## API Reference
|
|
160
|
+
|
|
161
|
+
### `useMarkdownEditor(initialContent?: string)`
|
|
162
|
+
|
|
163
|
+
Returns a reactive editor instance:
|
|
164
|
+
|
|
165
|
+
| Member | Type | Description |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `markdownContent` | `Ref<string>` | Reactive raw Markdown. Read to serialize, write to load content. |
|
|
168
|
+
| `markdownNodes` | `Ref<MarkdownAstNode[]>` | Reactive array of AST nodes. |
|
|
169
|
+
| `deleteNode(index)` | `(index: number) => void` | Remove the node at `index`. |
|
|
170
|
+
| `addBlankNode(index?)` | `(index?: number) => number` | Insert an empty paragraph at `index` (or end). Returns the new index. |
|
|
171
|
+
| `addNodeWithType(index, type, content?)` | `(index: number, type: MarkdownNodeType, content?: string) => number` | Insert a typed node. Returns the new index. |
|
|
172
|
+
| `replaceNodeType(node, newType)` | `(node: MarkdownAstNode, type: MarkdownNodeType) => { newNode, index } \| null` | Convert between block types (e.g. paragraph → heading). |
|
|
173
|
+
| `moveNode(from, to)` | `(fromIndex: number, toIndex: number) => void` | Programmatically reorder a block. |
|
|
174
|
+
|
|
175
|
+
### `MarkdownEditor` props
|
|
176
|
+
|
|
177
|
+
| Prop | Type | Required | Description |
|
|
178
|
+
|---|---|---|---|
|
|
179
|
+
| `editor` | `MarkdownEditorInstance` | ✓ | Instance from `useMarkdownEditor()`. |
|
|
180
|
+
| `focusedNode` | `MarkdownAstNode \| null` | — | For `v-model:focused-node` tracking. |
|
|
181
|
+
| `imageUploadFunction` | `(file: File) => Promise<string>` | — | Async callback for paste‑to‑upload. |
|
|
182
|
+
|
|
183
|
+
### `MarkdownEditor` emits
|
|
184
|
+
|
|
185
|
+
| Event | Payload | Description |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `update:focused-node` | `MarkdownAstNode \| null` | Fires when focus moves to a new block. |
|
|
188
|
+
|
|
189
|
+
### `MarkdownEditor` slots
|
|
190
|
+
|
|
191
|
+
| Slot | Description |
|
|
192
|
+
|---|---|
|
|
193
|
+
| `after-controls` | Injected inside every block, after the drag‑handle / add / delete controls. |
|
|
194
|
+
|
|
195
|
+
### Exported types & utilities
|
|
196
|
+
|
|
197
|
+
| Export | Kind |
|
|
198
|
+
|---|---|
|
|
199
|
+
| `MarkdownEditorInstance` | Type — return type of `useMarkdownEditor()`. |
|
|
200
|
+
| `MarkdownAstNode` | Class — AST node with `id`, `type`, `componentState`, `editingState`. |
|
|
201
|
+
| `MarkdownAstNodeType` | Enum — `PARAGRAPH`, `HEADLINE1`, `HEADLINE2`, `HEADLINE3`, `IMAGE`, `LIST`. |
|
|
202
|
+
| `ImageNode` | Type alias — `MarkdownAstNode<MarkdownModuleImageState>`. |
|
|
203
|
+
| `TextNode` | Type alias — `MarkdownAstNode<MarkdownModuleTextState>`. |
|
|
204
|
+
| `TextishNodeType` | Type — union of `PARAGRAPH \| HEADLINE1 \| HEADLINE2 \| HEADLINE3 \| LIST`. |
|
|
205
|
+
| `isTextNodeState(node)` | Type guard for text‑based nodes. |
|
|
206
|
+
| `isTextNodeType(type)` | Type guard for text‑based node types. |
|
|
207
|
+
| `MarkdownRenderer` | Component — renders Markdown to plain HTML. SSR‑safe. |
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## MarkdownRenderer
|
|
212
|
+
|
|
213
|
+
`<MarkdownRenderer>` converts a Markdown string to plain HTML. It uses the same `remark-parse` pipeline as the editor, so custom module blocks (like `"""MarkdownModuleImage"""`) render as rich `<figure>` elements automatically.
|
|
214
|
+
|
|
215
|
+
The renderer is **SSR‑safe** — no browser APIs, no TipTap. It works in `nuxt generate`, `vite-ssg`, and any server‑side rendering context.
|
|
216
|
+
|
|
217
|
+
```vue
|
|
218
|
+
<script setup lang="ts">
|
|
219
|
+
import { MarkdownRenderer } from "@grandaniel/vue-markdown-editor";
|
|
220
|
+
|
|
221
|
+
const markdown = `# Hello World
|
|
222
|
+
|
|
223
|
+
This is a **paragraph** with *inline* formatting.
|
|
224
|
+
|
|
225
|
+
- List item 1
|
|
226
|
+
- List item 2
|
|
227
|
+
|
|
228
|
+
"""MarkdownModuleImage
|
|
229
|
+
src: https://example.com/photo.jpg
|
|
230
|
+
alt: A scenic view
|
|
231
|
+
caption: Photo caption
|
|
232
|
+
"""
|
|
233
|
+
`;
|
|
234
|
+
</script>
|
|
235
|
+
|
|
236
|
+
<template>
|
|
237
|
+
<MarkdownRenderer :markdown="markdown" />
|
|
238
|
+
</template>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### Live preview alongside the editor
|
|
242
|
+
|
|
243
|
+
Bind the editor's reactive `markdownContent` to the renderer:
|
|
244
|
+
|
|
245
|
+
```vue
|
|
246
|
+
<script setup lang="ts">
|
|
247
|
+
import { MarkdownEditor, MarkdownRenderer, useMarkdownEditor } from "@grandaniel/vue-markdown-editor";
|
|
248
|
+
|
|
249
|
+
const editor = useMarkdownEditor("# Start writing…");
|
|
250
|
+
</script>
|
|
251
|
+
|
|
252
|
+
<template>
|
|
253
|
+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
|
|
254
|
+
<MarkdownEditor :editor="editor" />
|
|
255
|
+
<MarkdownRenderer :markdown="editor.markdownContent.value" />
|
|
256
|
+
</div>
|
|
257
|
+
</template>
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Props
|
|
261
|
+
|
|
262
|
+
| Prop | Type | Required | Description |
|
|
263
|
+
|---|---|---|---|
|
|
264
|
+
| `markdown` | `string` | ✓ | Raw Markdown string to render as HTML. |
|
|
265
|
+
|
|
266
|
+
---
|
|
267
|
+
|
|
268
|
+
## Custom Styling
|
|
269
|
+
|
|
270
|
+
All components use scoped SCSS. To override styles, use **global CSS** with higher specificity, or Vue's `:deep()` combinator from a parent component.
|
|
271
|
+
|
|
272
|
+
### CSS class reference
|
|
273
|
+
|
|
274
|
+
| Class | Applies to |
|
|
275
|
+
|---|---|
|
|
276
|
+
| `.markdown-editor` | Root editor container |
|
|
277
|
+
| `.markdown-editor-module` | Individual block wrapper — `.is-focused` when active |
|
|
278
|
+
| `.markdown-editor-module-controls` | Left control bar (drag handle + add/delete buttons) |
|
|
279
|
+
| `.markdown-editor-module-content` | Content area inside a block |
|
|
280
|
+
| `.markdown-editor-module-content-focused` | Content area when the block is focused |
|
|
281
|
+
| `.markdown-editor-focus-controls` | Row containing drag‑handle, delete, and add buttons |
|
|
282
|
+
| `.drag-handle` | SortableJS drag handle (⠿) |
|
|
283
|
+
| `.focus-control-btn` | Delete / Add buttons in the control bar |
|
|
284
|
+
| `.markdown-editor-context-menu` | Floating block context menu (`z-index: 1000`) |
|
|
285
|
+
| `.markdown-editor-context-menu-block-item` | Full‑width context menu button |
|
|
286
|
+
| `.markdown-editor-context-menu-inline-item` | Inline toolbar button (`.is-active` when toggled) |
|
|
287
|
+
| `.markdown-editor-modal-overlay` | Modal backdrop (`z-index: 9999`) |
|
|
288
|
+
| `.markdown-editor-modal` | Modal container |
|
|
289
|
+
| `.markdown-editor-modal-header` | Modal title bar |
|
|
290
|
+
| `.markdown-editor-modal-title` | Modal heading text |
|
|
291
|
+
| `.markdown-editor-modal-close` | Close (✕) button |
|
|
292
|
+
| `.markdown-editor-modal-body` | Modal content area |
|
|
293
|
+
| `.markdown-editor-modal-footer` | Modal action bar |
|
|
294
|
+
| `.markdown-editor-modal-button` | Base modal button |
|
|
295
|
+
| `.markdown-editor-modal-button-primary` | Primary (Save) button — blue |
|
|
296
|
+
| `.markdown-editor-modal-button-secondary` | Secondary (Cancel) button — gray |
|
|
297
|
+
| `.markdown-module-image` | Image block wrapper |
|
|
298
|
+
| `.markdown-module-image-form` | Image edit form inside the modal |
|
|
299
|
+
| `.markdown-module-image-form-field` | Form field group (label + input) |
|
|
300
|
+
|
|
301
|
+
### Styling TipTap content
|
|
302
|
+
|
|
303
|
+
Each text‑based block hosts its own TinyMCE‑style TipTap editor. Target `.tiptap` inside a block's content area:
|
|
304
|
+
|
|
305
|
+
```css
|
|
306
|
+
/* Make all TipTap editors use your font */
|
|
307
|
+
.markdown-editor-module-content .tiptap {
|
|
308
|
+
font-family: "Georgia", serif;
|
|
309
|
+
font-size: 1.1rem;
|
|
310
|
+
line-height: 1.8;
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### Do's
|
|
315
|
+
|
|
316
|
+
- ✅ Import the stylesheet: `import "@grandaniel/vue-markdown-editor/style.css"`
|
|
317
|
+
- ✅ Use **global** (unscoped) CSS or `:deep()` from a parent to override styles
|
|
318
|
+
- ✅ Target `.tiptap` inside `.markdown-editor-module-content` for editor typography
|
|
319
|
+
- ✅ Use `z-index` values above `1000` / `9999` for anything that must layer **above** context menus and modals
|
|
320
|
+
|
|
321
|
+
### Don'ts
|
|
322
|
+
|
|
323
|
+
- ❌ Don't rely on CSS custom properties — the editor uses hard‑coded Tailwind‑scale colors (grays and blues)
|
|
324
|
+
- ❌ Don't override `z-index` on `.markdown-editor-context-menu` or `.markdown-editor-modal-overlay` — it will break layering
|
|
325
|
+
- ❌ Don't use `display: contents` on `.markdown-editor-module` — it interferes with SortableJS drag logic
|
|
326
|
+
- ❌ Don't set `outline: none` on `.markdown-editor-module-content` globally — the focus ring is intentional for keyboard navigation
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Supply Chain Security
|
|
331
|
+
|
|
332
|
+
We take package integrity seriously.
|
|
333
|
+
|
|
334
|
+
| Measure | Status |
|
|
335
|
+
|---|---|
|
|
336
|
+
| **npm package provenance** | ✅ Enabled — every publish includes [provenance attestations](https://docs.npmjs.com/generating-provenance-statements) via GitHub Actions and Sigstore. |
|
|
337
|
+
| **CI/CD** | ✅ GitHub Actions runs `npm ci` → `npm test` → `npm run build` → publish on every push to `dev` and `main`. |
|
|
338
|
+
| **Prerelease tags** | ✅ Non‑main branches publish with a `dev` dist‑tag (e.g. `1.1.3-dev.abc1234`). |
|
|
339
|
+
| **Dependabot** | 🔜 Planned — automated dependency update PRs will be enabled via `.github/dependabot.yml`. |
|
|
340
|
+
|
|
341
|
+
To verify provenance locally:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
npm audit signatures
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Contributing
|
|
350
|
+
|
|
351
|
+
We welcome contributions! Please follow the guidelines below.
|
|
352
|
+
|
|
353
|
+
### PR Policy
|
|
354
|
+
|
|
355
|
+
1. **Fork** the repository and create a feature branch off `dev`.
|
|
356
|
+
2. **Keep changes focused** — one feature or fix per PR.
|
|
357
|
+
3. **Add tests** for any new functionality. The project uses [Vitest](https://vitest.dev/) + [`@vue/test-utils`](https://test-utils.vuejs.org/).
|
|
358
|
+
4. **Run the full check** before pushing:
|
|
359
|
+
|
|
360
|
+
```bash
|
|
361
|
+
npm ci
|
|
362
|
+
npm run test
|
|
363
|
+
npm run type-check
|
|
364
|
+
npm run build
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
5. **Open a PR** against the `dev` branch with a clear description of what changed and why.
|
|
368
|
+
|
|
369
|
+
### Dev setup
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
# Clone and install
|
|
373
|
+
git clone https://github.com/danielgran/vue-markdown-editor.git
|
|
374
|
+
cd vue-markdown-editor
|
|
375
|
+
npm ci
|
|
376
|
+
|
|
377
|
+
# Start the dev server
|
|
378
|
+
npm run dev
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
The dev server launches at `http://localhost:4010`. The entry point is `dev/App.vue` — a full showcase that demonstrates every editor feature: block types, drag & drop, image upload, context menus, keyboard navigation, and Markdown output serialization. Use it as a playground while developing.
|
|
382
|
+
|
|
383
|
+
---
|
|
384
|
+
|
|
385
|
+
## License
|
|
386
|
+
|
|
387
|
+
[ISC](LICENSE) © 2026 [danielgran](https://github.com/danielgran)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ShallowRef } from "vue";
|
|
2
|
+
import type { Editor } from "@tiptap/vue-3";
|
|
3
|
+
/**
|
|
4
|
+
* Singleton reactive reference to the currently focused TipTap editor.
|
|
5
|
+
* Set by each TipTap module on focus, read by the text selection context menu.
|
|
6
|
+
*/
|
|
7
|
+
export declare const activeEditor: ShallowRef<Editor | null>;
|
|
@@ -17,6 +17,7 @@ export declare function useMarkdownEditor(initialContent?: string): {
|
|
|
17
17
|
}[]>;
|
|
18
18
|
deleteNode: (nodeIndex: number) => void;
|
|
19
19
|
addBlankNode: (nodeIndex: number) => number;
|
|
20
|
+
addNodeWithType: (nodeIndex: number, type: import("..").MarkdownAstNodeType, content?: string) => number;
|
|
20
21
|
replaceNodeType: (node: import("..").MarkdownAstNode, newType: import("..").MarkdownAstNodeType) => {
|
|
21
22
|
newNode: import("..").MarkdownAstNode;
|
|
22
23
|
index: number;
|
|
@@ -19,6 +19,7 @@ declare function useMarkdownProcessor(modelValue: ModelRef<string | undefined>):
|
|
|
19
19
|
}[]>;
|
|
20
20
|
deleteNode: (nodeIndex: number) => void;
|
|
21
21
|
addBlankNode: (nodeIndex: number) => number;
|
|
22
|
+
addNodeWithType: (nodeIndex: number, type: MarkdownNodeType, content?: string) => number;
|
|
22
23
|
replaceNodeType: (node: MarkdownAstNode, newType: MarkdownNodeType) => {
|
|
23
24
|
newNode: MarkdownAstNode;
|
|
24
25
|
index: number;
|
|
@@ -3,9 +3,11 @@ type __VLS_Props = {
|
|
|
3
3
|
y: number;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
6
|
-
|
|
6
|
+
editAttributes: () => any;
|
|
7
|
+
close: () => any;
|
|
7
8
|
}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
|
|
8
|
-
|
|
9
|
+
onEditAttributes?: (() => any) | undefined;
|
|
10
|
+
onClose?: (() => any) | undefined;
|
|
9
11
|
}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
10
12
|
declare const _default: typeof __VLS_export;
|
|
11
13
|
export default _default;
|
|
@@ -15,6 +15,10 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
|
|
|
15
15
|
required: false;
|
|
16
16
|
default: null;
|
|
17
17
|
};
|
|
18
|
+
imageUploadFunction: {
|
|
19
|
+
type: PropType<(file: File) => Promise<string>>;
|
|
20
|
+
required: false;
|
|
21
|
+
};
|
|
18
22
|
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
|
|
19
23
|
"update:focused-node": (value: MarkdownAstNode<object> | null) => any;
|
|
20
24
|
}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
@@ -27,6 +31,10 @@ declare const __VLS_base: import("vue").DefineComponent<import("vue").ExtractPro
|
|
|
27
31
|
required: false;
|
|
28
32
|
default: null;
|
|
29
33
|
};
|
|
34
|
+
imageUploadFunction: {
|
|
35
|
+
type: PropType<(file: File) => Promise<string>>;
|
|
36
|
+
required: false;
|
|
37
|
+
};
|
|
30
38
|
}>> & Readonly<{
|
|
31
39
|
"onUpdate:focused-node"?: ((value: MarkdownAstNode<object> | null) => any) | undefined;
|
|
32
40
|
}>, {
|
|
@@ -3,6 +3,7 @@ type __VLS_ModelProps = {
|
|
|
3
3
|
modelValue: MarkdownModuleTextState;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {
|
|
6
|
+
editor: import("vue").ShallowRef<import("@tiptap/vue-3").Editor | undefined, import("@tiptap/vue-3").Editor | undefined>;
|
|
6
7
|
focus: () => void;
|
|
7
8
|
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
8
9
|
"update:model-value": (componentState: MarkdownModuleTextState) => any;
|
|
@@ -3,6 +3,7 @@ type __VLS_ModelProps = {
|
|
|
3
3
|
modelValue: MarkdownModuleTextState;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {
|
|
6
|
+
editor: import("vue").ShallowRef<import("@tiptap/vue-3").Editor | undefined, import("@tiptap/vue-3").Editor | undefined>;
|
|
6
7
|
focus: () => void;
|
|
7
8
|
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
8
9
|
"update:model-value": (componentState: MarkdownModuleTextState) => any;
|
|
@@ -3,6 +3,7 @@ type __VLS_ModelProps = {
|
|
|
3
3
|
modelValue: MarkdownModuleTextState;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {
|
|
6
|
+
editor: import("vue").ShallowRef<import("@tiptap/vue-3").Editor | undefined, import("@tiptap/vue-3").Editor | undefined>;
|
|
6
7
|
focus: () => void;
|
|
7
8
|
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
8
9
|
"update:model-value": (componentState: MarkdownModuleTextState) => any;
|
|
@@ -4,6 +4,7 @@ type __VLS_ModelProps = {
|
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {
|
|
6
6
|
focus(): void;
|
|
7
|
+
editor: import("vue").ShallowRef<import("@tiptap/vue-3").Editor | undefined, import("@tiptap/vue-3").Editor | undefined>;
|
|
7
8
|
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
8
9
|
"update:modelValue": (value: MarkdownModuleListState) => any;
|
|
9
10
|
}, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
|
|
@@ -3,6 +3,7 @@ type __VLS_ModelProps = {
|
|
|
3
3
|
modelValue: MarkdownModuleTextState;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {
|
|
6
|
+
editor: import("vue").ShallowRef<import("@tiptap/vue-3").Editor | undefined, import("@tiptap/vue-3").Editor | undefined>;
|
|
6
7
|
focus: () => void;
|
|
7
8
|
}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
|
|
8
9
|
"update:model-value": (componentState: MarkdownModuleTextState) => any;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Component } from "vue";
|
|
2
|
+
import MarkdownNodeType from "../MarkdownEditor/Types/MarkdownAstNodeType";
|
|
3
|
+
type RenderComponent = Component<{
|
|
4
|
+
state: any;
|
|
5
|
+
}>;
|
|
6
|
+
declare const RenderComponentRegistry: Record<MarkdownNodeType, RenderComponent>;
|
|
7
|
+
export default RenderComponentRegistry;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
declare const __VLS_export: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
2
|
+
markdown: {
|
|
3
|
+
type: StringConstructor;
|
|
4
|
+
required: true;
|
|
5
|
+
};
|
|
6
|
+
}>, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
7
|
+
markdown: {
|
|
8
|
+
type: StringConstructor;
|
|
9
|
+
required: true;
|
|
10
|
+
};
|
|
11
|
+
}>> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
12
|
+
declare const _default: typeof __VLS_export;
|
|
13
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type MarkdownModuleTextState from "../../MarkdownEditor/Modules/MarkdownModuleTextState";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
state: MarkdownModuleTextState;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type MarkdownModuleTextState from "../../MarkdownEditor/Modules/MarkdownModuleTextState";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
state: MarkdownModuleTextState;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type MarkdownModuleTextState from "../../MarkdownEditor/Modules/MarkdownModuleTextState";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
state: MarkdownModuleTextState;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type MarkdownModuleImageState from "../../MarkdownEditor/Modules/MarkdownModuleImageState";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
state: MarkdownModuleImageState;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type MarkdownModuleListState from "../../MarkdownEditor/Modules/MarkdownModuleListState";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
state: MarkdownModuleListState;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type MarkdownModuleTextState from "../../MarkdownEditor/Modules/MarkdownModuleTextState";
|
|
2
|
+
type __VLS_Props = {
|
|
3
|
+
state: MarkdownModuleTextState;
|
|
4
|
+
};
|
|
5
|
+
declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
|
|
6
|
+
declare const _default: typeof __VLS_export;
|
|
7
|
+
export default _default;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
.markdown-editor-context-menu[data-v-05f519e0]{z-index:1000;background:#fff;border:1px solid #e5e7eb;border-radius:.5rem;gap:.25rem;padding:.25rem;display:flex;position:fixed;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.markdown-editor-context-menu-inline-item[data-v-
|
|
1
|
+
.markdown-editor-context-menu[data-v-05f519e0]{z-index:1000;background:#fff;border:1px solid #e5e7eb;border-radius:.5rem;gap:.25rem;padding:.25rem;display:flex;position:fixed;box-shadow:0 4px 6px -1px #0000001a,0 2px 4px -1px #0000000f}.markdown-editor-context-menu-inline-item[data-v-7dc94914]{cursor:pointer;color:#374151;background:0 0;border:none;border-radius:.375rem;justify-content:center;align-items:center;min-width:2rem;min-height:2rem;padding:.5rem;font-size:.875rem;font-weight:500;transition:all .15s;display:flex}.markdown-editor-context-menu-inline-item[data-v-7dc94914]:hover{background:#f3f4f6}.markdown-editor-context-menu-inline-item.is-active[data-v-7dc94914]{color:#1e40af;background:#dbeafe}.markdown-editor-context-menu-inline-item[data-v-7dc94914]:active{transform:scale(.95)}.markdown-editor-focus-controls[data-v-ce3a9e83]{flex-direction:row;align-items:center;height:100%;display:flex}.markdown-editor-focus-controls .drag-handle[data-v-ce3a9e83]{cursor:grab;-webkit-user-select:none;user-select:none;opacity:.4;padding:0 .2rem;font-size:1rem;line-height:1}.markdown-editor-focus-controls .drag-handle[data-v-ce3a9e83]:active{cursor:grabbing}.markdown-editor-focus-controls .focus-control-btn[data-v-ce3a9e83]{cursor:pointer;opacity:.6;background:0 0;border:none;border-radius:3px;justify-content:center;align-items:center;width:1.4rem;height:1.4rem;padding:0;font-size:.75rem;line-height:1;transition:opacity .1s,background .1s;display:inline-flex}.markdown-editor-focus-controls .focus-control-btn[data-v-ce3a9e83]:hover{opacity:1;background:#00000012}[data-v-cf2b20ee],[data-v-6cb21ae4]{background:0 0;border:none;outline:none}.markdown-editor-context-menu-block-item[data-v-770b13bc]{cursor:pointer;color:#374151;text-align:left;white-space:nowrap;background:0 0;border:none;border-radius:.375rem;align-items:center;gap:.5rem;width:100%;padding:.625rem .875rem;font-size:.875rem;font-weight:500;transition:all .15s;display:flex}.markdown-editor-context-menu-block-item[data-v-770b13bc]:hover{background:#f3f4f6}.markdown-editor-context-menu-block-item[data-v-770b13bc]:active{transform:scale(.98)}.markdown-editor-image-context-menu-content[data-v-fbe7317c]{flex-direction:column;gap:.125rem;min-width:10rem;display:flex}.markdown-editor-modal-overlay[data-v-3e773d7f]{z-index:9999;background:#00000080;justify-content:center;align-items:center;padding:1rem;display:flex;position:fixed;inset:0}.markdown-editor-modal[data-v-3e773d7f]{background:#fff;border-radius:.75rem;flex-direction:column;width:100%;max-width:32rem;max-height:90vh;display:flex;box-shadow:0 20px 25px -5px #0000001a,0 10px 10px -5px #0000000a}.markdown-editor-modal-header[data-v-3e773d7f]{border-bottom:1px solid #e5e7eb;justify-content:space-between;align-items:center;padding:1.25rem 1.5rem;display:flex}.markdown-editor-modal-title[data-v-3e773d7f]{color:#111827;margin:0;font-size:1.125rem;font-weight:600}.markdown-editor-modal-close[data-v-3e773d7f]{color:#6b7280;cursor:pointer;background:0 0;border:none;border-radius:.375rem;justify-content:center;align-items:center;width:2rem;height:2rem;padding:0;font-size:1.75rem;line-height:1;transition:all .15s;display:flex}.markdown-editor-modal-close[data-v-3e773d7f]:hover{color:#111827;background:#f3f4f6}.markdown-editor-modal-body[data-v-3e773d7f]{flex:1;padding:1.5rem;overflow-y:auto}.markdown-editor-modal-footer[data-v-3e773d7f]{border-top:1px solid #e5e7eb;justify-content:flex-end;gap:.75rem;padding:1.25rem 1.5rem;display:flex}.markdown-editor-modal-button[data-v-3e773d7f]{cursor:pointer;border:none;border-radius:.5rem;padding:.625rem 1.25rem;font-size:.875rem;font-weight:500;transition:all .15s}.markdown-editor-modal-button[data-v-3e773d7f]:active{transform:scale(.98)}.markdown-editor-modal-button-secondary[data-v-3e773d7f]{color:#374151;background:#f3f4f6}.markdown-editor-modal-button-secondary[data-v-3e773d7f]:hover{background:#e5e7eb}.markdown-editor-modal-button-primary[data-v-3e773d7f]{color:#fff;background:#3b82f6}.markdown-editor-modal-button-primary[data-v-3e773d7f]:hover{background:#2563eb}.markdown-module-image[data-v-9489f41b]{cursor:pointer;text-align:center;position:relative}.markdown-module-image img[data-v-9489f41b]{max-width:100%;height:auto}.markdown-module-image span[data-v-9489f41b]{color:#6b7280;margin-top:.25rem;font-size:.875rem;display:block}.markdown-module-image-form[data-v-9489f41b]{flex-direction:column;gap:1.25rem;display:flex}.markdown-module-image-form-field[data-v-9489f41b]{flex-direction:column;gap:.5rem;display:flex}.markdown-module-image-form-field label[data-v-9489f41b]{color:#374151;font-size:.875rem;font-weight:500}.markdown-module-image-form-field input[data-v-9489f41b]{color:#111827;background:#fff;border:1px solid #d1d5db;border-radius:.5rem;padding:.625rem .875rem;font-size:.875rem;transition:all .15s}.markdown-module-image-form-field input[data-v-9489f41b]:focus{border-color:#3b82f6;outline:none;box-shadow:0 0 0 3px #3b82f61a}.markdown-module-image-form-field input[data-v-9489f41b]::placeholder{color:#9ca3af}[data-v-9489f41b]{background:0 0;border:none;outline:none}[data-v-2f49655e] .tiptap ul{margin:0;padding-left:1.5rem}.markdown-editor-module[data-v-965b9a8c]{flex-direction:row;display:flex}.markdown-editor-module[data-v-965b9a8c] .tiptap{outline:none}.markdown-editor-module .markdown-editor-module-content[data-v-965b9a8c]{outline:none;width:100%}.markdown-editor-module .markdown-editor-module-controls[data-v-965b9a8c]{visibility:hidden;min-width:5rem;max-width:5rem}.markdown-editor-module[data-v-965b9a8c]:hover{background:#9e95950d}.markdown-editor-module:hover .markdown-editor-module-controls[data-v-965b9a8c],.markdown-editor-module.is-focused .markdown-editor-module-controls[data-v-965b9a8c]{visibility:visible}.markdown-editor-module .markdown-editor-module-content-focused[data-v-965b9a8c]{background-color:#9e95950d}.markdown-editor[data-v-1f5fcb46]{padding-left:6rem}.markdown-editor[data-v-1f5fcb46] p{margin:.5rem 0}.markdown-editor[data-v-1f5fcb46] strong,.markdown-editor[data-v-1f5fcb46] b{font-weight:700}.markdown-editor[data-v-1f5fcb46] em,.markdown-editor[data-v-1f5fcb46] i{font-style:italic}.markdown-editor[data-v-1f5fcb46] code{color:green;background:#7f7f7f26;border-radius:3px;padding:.1em .3em;font-family:monospace;font-size:.9em}.markdown-editor[data-v-1f5fcb46] h1{margin:0;font-size:2em}.markdown-editor[data-v-1f5fcb46] h2{margin:0;font-size:1.5em}.markdown-editor[data-v-1f5fcb46] h3{margin:0;font-size:1.17em}
|
|
2
2
|
/*$vite$:1*/
|