@cocoar/vue-ui 3.1.0 → 3.2.0-beta.6
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/dist/components/checkbox/CoarCheckbox.vue.d.ts +1 -1
- package/dist/components/checkbox/CoarCheckbox.vue.d.ts.map +1 -1
- package/dist/components/code-block/CoarCodeBlock.vue.d.ts +1 -1
- package/dist/components/code-block/CoarCodeBlock.vue.d.ts.map +1 -1
- package/dist/components/data-list/CoarDataList.vue.d.ts +158 -0
- package/dist/components/data-list/CoarDataList.vue.d.ts.map +1 -0
- package/dist/components/data-list/CoarDataListToolbar.vue.d.ts +30 -0
- package/dist/components/data-list/CoarDataListToolbar.vue.d.ts.map +1 -0
- package/dist/components/data-list/data-list-builder.d.ts +288 -0
- package/dist/components/data-list/data-list-builder.d.ts.map +1 -0
- package/dist/components/data-list/index.d.ts +12 -0
- package/dist/components/data-list/index.d.ts.map +1 -0
- package/dist/components/data-list/internal/compare.d.ts +14 -0
- package/dist/components/data-list/internal/compare.d.ts.map +1 -0
- package/dist/components/data-list/internal/reorder-core.d.ts +46 -0
- package/dist/components/data-list/internal/reorder-core.d.ts.map +1 -0
- package/dist/components/data-list/internal/search.d.ts +16 -0
- package/dist/components/data-list/internal/search.d.ts.map +1 -0
- package/dist/components/data-list/internal/useDataListLines.d.ts +92 -0
- package/dist/components/data-list/internal/useDataListLines.d.ts.map +1 -0
- package/dist/components/data-list/internal/useDataListReorder.d.ts +68 -0
- package/dist/components/data-list/internal/useDataListReorder.d.ts.map +1 -0
- package/dist/components/data-list/internal/useSearchHighlight.d.ts +19 -0
- package/dist/components/data-list/internal/useSearchHighlight.d.ts.map +1 -0
- package/dist/components/data-list/types.d.ts +185 -0
- package/dist/components/data-list/types.d.ts.map +1 -0
- package/dist/components/data-list/useDataListModel.d.ts +79 -0
- package/dist/components/data-list/useDataListModel.d.ts.map +1 -0
- package/dist/components/date-time/_shared/maskito-config.d.ts +7 -2
- package/dist/components/date-time/_shared/maskito-config.d.ts.map +1 -1
- package/dist/components/date-time/_shared/time-helpers.d.ts +11 -4
- package/dist/components/date-time/_shared/time-helpers.d.ts.map +1 -1
- package/dist/components/date-time/_shared/use-date-picker-base.d.ts +1 -1
- package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts +1 -1
- package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts.map +1 -1
- package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts +1 -1
- package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts.map +1 -1
- package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts +1 -1
- package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts.map +1 -1
- package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts +3 -0
- package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts.map +1 -1
- package/dist/components/form-field/CoarFormField.vue.d.ts +1 -1
- package/dist/components/form-field/CoarFormField.vue.d.ts.map +1 -1
- package/dist/components/icon/CoarIcon.vue.d.ts +2 -2
- package/dist/components/icon/CoarIcon.vue.d.ts.map +1 -1
- package/dist/components/listbox/CoarListbox.vue.d.ts +7 -0
- package/dist/components/listbox/CoarListbox.vue.d.ts.map +1 -1
- package/dist/components/number-input/CoarNumberInput.vue.d.ts +1 -1
- package/dist/components/number-input/CoarNumberInput.vue.d.ts.map +1 -1
- package/dist/components/otp-input/CoarOtpInput.vue.d.ts +1 -1
- package/dist/components/otp-input/CoarOtpInput.vue.d.ts.map +1 -1
- package/dist/components/password-input/CoarPasswordInput.vue.d.ts +1 -1
- package/dist/components/password-input/CoarPasswordInput.vue.d.ts.map +1 -1
- package/dist/components/switch/CoarSwitch.vue.d.ts +1 -1
- package/dist/components/switch/CoarSwitch.vue.d.ts.map +1 -1
- package/dist/components/text-input/CoarTextInput.vue.d.ts +1 -1
- package/dist/components/text-input/CoarTextInput.vue.d.ts.map +1 -1
- package/dist/composables/dragRegistry.d.ts +12 -0
- package/dist/composables/dragRegistry.d.ts.map +1 -1
- package/dist/composables/index.d.ts +2 -2
- package/dist/composables/index.d.ts.map +1 -1
- package/dist/composables/useDragDrop.d.ts +62 -6
- package/dist/composables/useDragDrop.d.ts.map +1 -1
- package/dist/composables/useVirtualList.d.ts +21 -1
- package/dist/composables/useVirtualList.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4508 -2039
- package/package.json +5 -3
- package/skills/cocoar-vue-ui/SKILL.md +244 -0
- package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
- package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
- package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
- package/skills/cocoar-vue-ui/references/components/button.md +252 -0
- package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +353 -0
- package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1645 -0
- package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
- package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
- package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +475 -0
- package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
- package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
- package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
- package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
- package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
- package/skills/cocoar-vue-ui/references/components/card.md +151 -0
- package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
- package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
- package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
- package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
- package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
- package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
- package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
- package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
- package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
- package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
- package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
- package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
- package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
- package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
- package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
- package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
- package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
- package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
- package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
- package/skills/cocoar-vue-ui/references/components/link.md +235 -0
- package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
- package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
- package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
- package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
- package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
- package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
- package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
- package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
- package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
- package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
- package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
- package/skills/cocoar-vue-ui/references/components/note.md +96 -0
- package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
- package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
- package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
- package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
- package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
- package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
- package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
- package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
- package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
- package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
- package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
- package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
- package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
- package/skills/cocoar-vue-ui/references/components/select.md +463 -0
- package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
- package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
- package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
- package/skills/cocoar-vue-ui/references/components/table.md +170 -0
- package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
- package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
- package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
- package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
- package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
- package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
- package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
- package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
- package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
- package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
- package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
- package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
- package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
- package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
- package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
- package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
- package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
- package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
- package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
- package/skills/cocoar-vue-ui/references/guide/getting-started.md +119 -0
- package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
- package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
- package/skills/cocoar-vue-ui/references/guide/theming.md +98 -0
|
@@ -0,0 +1,1478 @@
|
|
|
1
|
+
<!-- Generated from apps/docs/components/markdown-editor.md by apps/docs/scripts/sync-skill.mjs. Do not edit; edit the docs page. -->
|
|
2
|
+
|
|
3
|
+
# Markdown Editor (Preview)
|
|
4
|
+
|
|
5
|
+
WYSIWYG Markdown editor for Vue 3 based on [Milkdown](https://milkdown.dev/) (Kit approach), styled with the Cocoar Design System. Markdown-first: lossless round-trip between text and editor state. Shares the same remark stack — and the same render registry — as `@cocoar/vue-markdown-core` and `<CoarMarkdown>`.
|
|
6
|
+
|
|
7
|
+
> **Info: Separate Package**
|
|
8
|
+
>
|
|
9
|
+
>
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @cocoar/vue-markdown-editor @cocoar/vue-markdown @cocoar/vue-ui
|
|
12
|
+
```
|
|
13
|
+
>
|
|
14
|
+
> `@cocoar/vue-markdown`, `@cocoar/vue-ui` and `vue` are peer dependencies. Milkdown is bundled as a regular dependency — no extra setup required. The peer-dep on `@cocoar/vue-markdown` is what makes the **shared rendering registry** work — code blocks, tables, etc. look identical here and in `<CoarMarkdown>`.
|
|
15
|
+
>
|
|
16
|
+
> Import the stylesheets once at your app's entry — same pattern as `@cocoar/vue-ui`:
|
|
17
|
+
>
|
|
18
|
+
```css
|
|
19
|
+
/* app/main.css */
|
|
20
|
+
@import '@cocoar/vue-ui/styles';
|
|
21
|
+
@import '@cocoar/vue-markdown/styles'; /* ← shared block styles */
|
|
22
|
+
@import '@cocoar/vue-markdown-editor/styles'; /* ← editor-specific chrome */
|
|
23
|
+
```
|
|
24
|
+
>
|
|
25
|
+
|
|
26
|
+
> **Warning: Preview release**
|
|
27
|
+
>
|
|
28
|
+
> The package is on the `0.0.x` line. The render layer, `v-model` contract, toolbar API, form-field integration, and code-block view/edit toggle are **stable enough to ship in internal Cocoar apps** — the source format is plain Markdown, so any content written today round-trips through future API changes.
|
|
29
|
+
>
|
|
30
|
+
> Still missing for a `1.0`: a link insertion dialog and hover-based table edge-handles (row/column selection). See [TODO](#todo) below.
|
|
31
|
+
|
|
32
|
+
## Basic Usage
|
|
33
|
+
|
|
34
|
+
The editor exposes a plain `v-model` for the markdown string and renders a floating toolbar on text selection.
|
|
35
|
+
|
|
36
|
+
**Demo — `markdown-editor/demos/MarkdownEditorBasic.vue`**
|
|
37
|
+
|
|
38
|
+
```vue
|
|
39
|
+
<template>
|
|
40
|
+
<ClientOnly>
|
|
41
|
+
<div class="md-frame">
|
|
42
|
+
<component :is="Editor" v-if="Editor" v-model="value" />
|
|
43
|
+
<div v-else class="md-frame__loading">Loading editor…</div>
|
|
44
|
+
</div>
|
|
45
|
+
<details class="md-output">
|
|
46
|
+
<summary>Raw markdown (v-model)</summary>
|
|
47
|
+
<pre>{{ value }}</pre>
|
|
48
|
+
</details>
|
|
49
|
+
</ClientOnly>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<script setup lang="ts">
|
|
53
|
+
import { onMounted, ref, shallowRef, type Component } from 'vue';
|
|
54
|
+
|
|
55
|
+
const value = ref(`# Try me
|
|
56
|
+
|
|
57
|
+
Select some text to see the **floating toolbar**, or insert a:
|
|
58
|
+
|
|
59
|
+
| Column A | Column B |
|
|
60
|
+
|----------|----------|
|
|
61
|
+
| Edit me | Edit me |
|
|
62
|
+
|
|
63
|
+
> Blockquote works too.
|
|
64
|
+
`);
|
|
65
|
+
|
|
66
|
+
const Editor = shallowRef<Component | null>(null);
|
|
67
|
+
|
|
68
|
+
onMounted(async () => {
|
|
69
|
+
const mod = await import('@cocoar/vue-markdown-editor');
|
|
70
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
71
|
+
});
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<style scoped>
|
|
75
|
+
.md-frame {
|
|
76
|
+
height: 360px;
|
|
77
|
+
border: 1px solid var(--coar-border-neutral);
|
|
78
|
+
border-radius: var(--coar-radius-xl);
|
|
79
|
+
overflow: hidden;
|
|
80
|
+
display: flex;
|
|
81
|
+
flex-direction: column;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.md-frame__loading {
|
|
85
|
+
padding: 24px;
|
|
86
|
+
text-align: center;
|
|
87
|
+
color: var(--coar-text-neutral-tertiary);
|
|
88
|
+
font-size: 13px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.md-output { margin-top: 12px; }
|
|
92
|
+
.md-output summary {
|
|
93
|
+
cursor: pointer;
|
|
94
|
+
font-size: 13px;
|
|
95
|
+
font-weight: 600;
|
|
96
|
+
}
|
|
97
|
+
.md-output pre {
|
|
98
|
+
margin-top: 8px;
|
|
99
|
+
padding: 12px;
|
|
100
|
+
background: var(--coar-background-neutral-secondary);
|
|
101
|
+
border-radius: var(--coar-radius-xl);
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
max-height: 200px;
|
|
104
|
+
overflow: auto;
|
|
105
|
+
white-space: pre-wrap;
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```vue
|
|
111
|
+
<template>
|
|
112
|
+
<CoarMarkdownEditor v-model="value" />
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<script setup lang="ts">
|
|
116
|
+
import { ref } from 'vue';
|
|
117
|
+
import { CoarMarkdownEditor } from '@cocoar/vue-markdown-editor';
|
|
118
|
+
|
|
119
|
+
const value = ref('# Hello\n\nStart typing **markdown**.');
|
|
120
|
+
</script>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
> **Tip: Sizing**
|
|
124
|
+
>
|
|
125
|
+
> The editor fills its parent container. Wrap it in a parent with explicit height (`height: 360px`, `flex: 1` inside a column flexbox, etc.).
|
|
126
|
+
|
|
127
|
+
## Toolbar Modes
|
|
128
|
+
|
|
129
|
+
`toolbarMode` controls the layout. Four values:
|
|
130
|
+
|
|
131
|
+
| Value | Description |
|
|
132
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
133
|
+
| `'floating'` (default) | Appears on text selection, teleported to `<body>`, context-aware (text vs. table) |
|
|
134
|
+
| `'fixed'` | `CoarSidebar` collapsed with icon buttons and flyout submenus, persistent. Sits on any of the four edges — see `toolbarPosition` |
|
|
135
|
+
| `'both'` | Both active simultaneously |
|
|
136
|
+
| `'external'` | Routes this editor's commands and tool list into one shared `CoarMarkdownToolbar` |
|
|
137
|
+
|
|
138
|
+
When `toolbarMode` is `'fixed'` or `'both'`, `toolbarPosition` controls which edge the toolbar attaches to. All four edges are supported — `'left'` and `'right'` give a vertical icon column, `'top'` and `'bottom'` switch to a horizontal toolbar above or below the editor area. Flyout submenus open in the corresponding direction (right for `left`, downward for `top`, etc.).
|
|
139
|
+
|
|
140
|
+
**Demo — `markdown-editor/demos/MarkdownEditorSidebar.vue`**
|
|
141
|
+
|
|
142
|
+
```vue
|
|
143
|
+
<template>
|
|
144
|
+
<ClientOnly>
|
|
145
|
+
<div style="display: flex; flex-direction: column; gap: 12px;">
|
|
146
|
+
<CoarSelect
|
|
147
|
+
v-model="position"
|
|
148
|
+
:options="positionOptions"
|
|
149
|
+
label="toolbar-position"
|
|
150
|
+
size="s"
|
|
151
|
+
style="width: 200px;"
|
|
152
|
+
/>
|
|
153
|
+
<div class="md-frame">
|
|
154
|
+
<component
|
|
155
|
+
:is="Editor"
|
|
156
|
+
v-if="Editor"
|
|
157
|
+
v-model="value"
|
|
158
|
+
toolbar-mode="fixed"
|
|
159
|
+
:toolbar-position="position"
|
|
160
|
+
/>
|
|
161
|
+
<div v-else class="md-frame__loading">Loading editor…</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</ClientOnly>
|
|
165
|
+
</template>
|
|
166
|
+
|
|
167
|
+
<script setup lang="ts">
|
|
168
|
+
import { onMounted, ref, shallowRef, type Component } from 'vue';
|
|
169
|
+
import { CoarSelect } from '@cocoar/vue-ui';
|
|
170
|
+
import type { CoarSelectOption } from '@cocoar/vue-ui';
|
|
171
|
+
|
|
172
|
+
type Position = 'left' | 'right' | 'top' | 'bottom';
|
|
173
|
+
|
|
174
|
+
const value = ref(`# Sidebar toolbar
|
|
175
|
+
|
|
176
|
+
Use the icon strip on any of the four edges for persistent access to formatting
|
|
177
|
+
commands. Hover **Headings** to open the flyout.
|
|
178
|
+
`);
|
|
179
|
+
|
|
180
|
+
const position = ref<Position>('left');
|
|
181
|
+
|
|
182
|
+
const positionOptions: CoarSelectOption<Position>[] = [
|
|
183
|
+
{ value: 'left', label: 'left' },
|
|
184
|
+
{ value: 'right', label: 'right' },
|
|
185
|
+
{ value: 'top', label: 'top' },
|
|
186
|
+
{ value: 'bottom', label: 'bottom' },
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
const Editor = shallowRef<Component | null>(null);
|
|
190
|
+
|
|
191
|
+
onMounted(async () => {
|
|
192
|
+
const mod = await import('@cocoar/vue-markdown-editor');
|
|
193
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
194
|
+
});
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<style scoped>
|
|
198
|
+
.md-frame {
|
|
199
|
+
height: 320px;
|
|
200
|
+
border: 1px solid var(--coar-border-neutral);
|
|
201
|
+
border-radius: var(--coar-radius-xl);
|
|
202
|
+
overflow: hidden;
|
|
203
|
+
display: flex;
|
|
204
|
+
flex-direction: column;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.md-frame__loading {
|
|
208
|
+
padding: 24px;
|
|
209
|
+
text-align: center;
|
|
210
|
+
color: var(--coar-text-neutral-tertiary);
|
|
211
|
+
font-size: 13px;
|
|
212
|
+
}
|
|
213
|
+
</style>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
```vue
|
|
217
|
+
<CoarMarkdownEditor v-model="value" toolbar-mode="fixed" toolbar-position="top" />
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### One toolbar for multiple editors
|
|
221
|
+
|
|
222
|
+
Wrap one toolbar and all participating editors in `CoarMarkdownEditorGroup`.
|
|
223
|
+
Every editor uses `toolbar-mode="external"`:
|
|
224
|
+
|
|
225
|
+
```vue
|
|
226
|
+
<script setup lang="ts">
|
|
227
|
+
import { ref } from 'vue';
|
|
228
|
+
import {
|
|
229
|
+
CoarMarkdownEditor,
|
|
230
|
+
CoarMarkdownEditorGroup,
|
|
231
|
+
CoarMarkdownToolbar,
|
|
232
|
+
} from '@cocoar/vue-markdown-editor';
|
|
233
|
+
|
|
234
|
+
const document = ref('');
|
|
235
|
+
const notes = ref('');
|
|
236
|
+
</script>
|
|
237
|
+
|
|
238
|
+
<template>
|
|
239
|
+
<CoarMarkdownEditorGroup>
|
|
240
|
+
<CoarMarkdownToolbar position="top" />
|
|
241
|
+
|
|
242
|
+
<CoarMarkdownEditor
|
|
243
|
+
v-model="document"
|
|
244
|
+
toolbar-mode="external"
|
|
245
|
+
:tools="['bold', 'headings', 'bulletList', 'table']"
|
|
246
|
+
/>
|
|
247
|
+
|
|
248
|
+
<CoarMarkdownEditor
|
|
249
|
+
v-model="notes"
|
|
250
|
+
toolbar-mode="external"
|
|
251
|
+
:tools="['bold', 'italic', 'bulletList']"
|
|
252
|
+
/>
|
|
253
|
+
</CoarMarkdownEditorGroup>
|
|
254
|
+
</template>
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
There is exactly one toolbar DOM host. Focusing an editor switches the active
|
|
258
|
+
command controller and its available `tools`; the toolbar is not hidden,
|
|
259
|
+
remounted or teleported during that switch. When focus is outside every editor
|
|
260
|
+
in the group, the toolbar stays visible but is disabled. Clicking the toolbar
|
|
261
|
+
does not lose the active editor.
|
|
262
|
+
|
|
263
|
+
Editors in the same group may expose different tool lists. Editors outside the
|
|
264
|
+
group and editors using `floating`, `fixed` or `both` remain independent.
|
|
265
|
+
|
|
266
|
+
## Flavors (portability)
|
|
267
|
+
|
|
268
|
+
The **`flavor`** prop is a portability contract: it picks which features the editor offers and **hard-enforces** them — it only registers the matching Milkdown plugins, so a non-flavor construct can't be typed _or pasted_ (it degrades to plain text), and its toolbar buttons are hidden.
|
|
269
|
+
|
|
270
|
+
This matters when the same Markdown is rendered somewhere stricter than the web — e.g. a **native SwiftUI Markdown view** that only understands CommonMark, or CommonMark+GFM. Pick the flavor that matches your strictest renderer and authors physically can't produce content it won't render.
|
|
271
|
+
|
|
272
|
+
| Flavor | Adds on top of CommonMark | Renders in |
|
|
273
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
|
274
|
+
| `'commonmark'` | _(nothing — the portable floor)_ headings, bold/italic, lists, links, images, code, blockquote, hr | **any** Markdown renderer |
|
|
275
|
+
| `'gfm'` | tables, task lists, strikethrough | GFM-capable renderers (GitHub, swift-markdown-ui, …) |
|
|
276
|
+
| `'cocoar'` _(default)_ | inline **text color** + **custom embeds** (non-portable) | the Cocoar viewer / your own renderer |
|
|
277
|
+
|
|
278
|
+
```vue
|
|
279
|
+
<!-- Strict: only portable CommonMark can be authored -->
|
|
280
|
+
<CoarMarkdownEditor v-model="value" flavor="commonmark" toolbar-mode="both" />
|
|
281
|
+
|
|
282
|
+
<!-- Fine control: GFM tables etc. but no color, via a capability object -->
|
|
283
|
+
<CoarMarkdownEditor v-model="value" :flavor="{ gfm: true, textColor: false }" />
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
A capability object (`{ gfm?, textColor?, embeds? }`) is **opt-in** — unspecified capabilities are off, so `{}` ≡ `'commonmark'`. The default is `'cocoar'`, which also enables `embeds` — see [Custom Embeds](./markdown-embeds.md).
|
|
287
|
+
|
|
288
|
+
> **Tip: flavor vs. tools**
|
|
289
|
+
>
|
|
290
|
+
> `flavor` is the **hard format contract** (what can exist in the document). The [`tools`](#toolbar-layout-tools) layout is **soft toolbar curation** (which buttons show, and where) _within_ the flavor — e.g. keep GFM parsing but hide the table button. They compose.
|
|
291
|
+
|
|
292
|
+
> **Warning: Changing flavor at runtime**
|
|
293
|
+
>
|
|
294
|
+
> Plugin registration happens once at mount. To switch `flavor` on a live editor, **re-key** the component (`:key="flavor"`) so it remounts and re-registers — otherwise only the toolbar updates, not the parser. Switching to a stricter flavor degrades unsupported constructs already in the document (a table becomes its literal `| … |` text). The standalone `<CoarMarkdown>` viewer has its own parse options and is not affected by the editor's flavor.
|
|
295
|
+
|
|
296
|
+
## Readonly
|
|
297
|
+
|
|
298
|
+
```vue
|
|
299
|
+
<CoarMarkdownEditor v-model="value" readonly />
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
In `readonly` mode the editor accepts no input, the floating toolbar is suppressed, and the sidebar buttons are inert. The fixed toolbar still renders so the layout stays stable when toggling between view and edit.
|
|
303
|
+
|
|
304
|
+
## Placeholder
|
|
305
|
+
|
|
306
|
+
Pass a `placeholder` string to show a hint while the editor is empty. The
|
|
307
|
+
placeholder **is itself Markdown** — it renders through the same viewer the
|
|
308
|
+
editor uses for content, so `**bold**`, lists, and headings all work and match
|
|
309
|
+
the editor's typography.
|
|
310
|
+
|
|
311
|
+
```vue
|
|
312
|
+
<CoarMarkdownEditor
|
|
313
|
+
v-model="value"
|
|
314
|
+
:placeholder="'**Describe the change…**\n\n- What changed?\n- Why?'"
|
|
315
|
+
/>
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Demo — `markdown-editor/demos/MarkdownEditorPlaceholder.vue`**
|
|
319
|
+
|
|
320
|
+
```vue
|
|
321
|
+
<template>
|
|
322
|
+
<ClientOnly>
|
|
323
|
+
<div class="md-frame">
|
|
324
|
+
<component
|
|
325
|
+
:is="Editor"
|
|
326
|
+
v-if="Editor"
|
|
327
|
+
v-model="value"
|
|
328
|
+
:placeholder="placeholder"
|
|
329
|
+
/>
|
|
330
|
+
<div v-else class="md-frame__loading">Loading editor…</div>
|
|
331
|
+
</div>
|
|
332
|
+
<p class="md-hint">
|
|
333
|
+
The hint above is a decoration only. Until you type, the raw
|
|
334
|
+
<code>v-model</code> stays an empty string — nothing gets persisted.
|
|
335
|
+
</p>
|
|
336
|
+
<details class="md-output">
|
|
337
|
+
<summary>Raw markdown (v-model) — <code>{{ value.length }}</code> chars</summary>
|
|
338
|
+
<pre>{{ value || '(empty)' }}</pre>
|
|
339
|
+
</details>
|
|
340
|
+
</ClientOnly>
|
|
341
|
+
</template>
|
|
342
|
+
|
|
343
|
+
<script setup lang="ts">
|
|
344
|
+
import { onMounted, ref, shallowRef, type Component } from 'vue';
|
|
345
|
+
|
|
346
|
+
const value = ref('');
|
|
347
|
+
|
|
348
|
+
// The placeholder is itself Markdown — it renders through the same viewer the
|
|
349
|
+
// editor uses for content, so **bold**, lists and headings all work.
|
|
350
|
+
const placeholder = `**Describe the change…**
|
|
351
|
+
|
|
352
|
+
- What changed?
|
|
353
|
+
- Why does it matter?`;
|
|
354
|
+
|
|
355
|
+
const Editor = shallowRef<Component | null>(null);
|
|
356
|
+
|
|
357
|
+
onMounted(async () => {
|
|
358
|
+
const mod = await import('@cocoar/vue-markdown-editor');
|
|
359
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
360
|
+
});
|
|
361
|
+
</script>
|
|
362
|
+
|
|
363
|
+
<style scoped>
|
|
364
|
+
.md-frame {
|
|
365
|
+
height: 240px;
|
|
366
|
+
border: 1px solid var(--coar-border-neutral);
|
|
367
|
+
border-radius: var(--coar-radius-xl);
|
|
368
|
+
overflow: hidden;
|
|
369
|
+
display: flex;
|
|
370
|
+
flex-direction: column;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.md-frame__loading {
|
|
374
|
+
padding: 24px;
|
|
375
|
+
text-align: center;
|
|
376
|
+
color: var(--coar-text-neutral-tertiary);
|
|
377
|
+
font-size: 13px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.md-hint {
|
|
381
|
+
margin-top: 8px;
|
|
382
|
+
font-size: 13px;
|
|
383
|
+
color: var(--coar-text-neutral-secondary);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.md-output { margin-top: 12px; }
|
|
387
|
+
.md-output summary {
|
|
388
|
+
cursor: pointer;
|
|
389
|
+
font-size: 13px;
|
|
390
|
+
font-weight: 600;
|
|
391
|
+
}
|
|
392
|
+
.md-output pre {
|
|
393
|
+
margin-top: 8px;
|
|
394
|
+
padding: 12px;
|
|
395
|
+
background: var(--coar-background-neutral-secondary);
|
|
396
|
+
border-radius: var(--coar-radius-xl);
|
|
397
|
+
font-size: 12px;
|
|
398
|
+
max-height: 200px;
|
|
399
|
+
overflow: auto;
|
|
400
|
+
white-space: pre-wrap;
|
|
401
|
+
}
|
|
402
|
+
</style>
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
> **Tip: Never persisted — unlike pre-filling the value**
|
|
406
|
+
>
|
|
407
|
+
> The placeholder is a muted, click-through **overlay** of the `<CoarMarkdown>` viewer, shown only while the document is empty. It never enters `modelValue`. An untouched editor therefore still emits an empty string — so you can leave the field genuinely blank.
|
|
408
|
+
>
|
|
409
|
+
> Do **not** work around a missing placeholder by writing the hint into `v-model`: that turns the hint into real content, which then gets saved even when the user meant to leave the field empty.
|
|
410
|
+
|
|
411
|
+
The hint disappears the moment the document has any content and reappears if it's emptied again. It tracks the live `placeholder` prop, so you can swap it at runtime. Because it's a real Markdown render, the placeholder also picks up [custom renderers](#code-blocks-view-edit-toggle) you provide via `MARKDOWN_RENDERERS_KEY`.
|
|
412
|
+
|
|
413
|
+
## Frontmatter
|
|
414
|
+
|
|
415
|
+
A leading YAML frontmatter block (`---` … `---`) is recognised and shown as muted, italic `key: value` lines instead of being mis-parsed as a thematic break + setext heading (which collapses the whole block onto one line). It renders the same way in the [viewer](./markdown.md#frontmatter), so editing and reading look identical.
|
|
416
|
+
|
|
417
|
+
**Demo — `markdown-editor/demos/MarkdownEditorFrontmatter.vue`**
|
|
418
|
+
|
|
419
|
+
```vue
|
|
420
|
+
<template>
|
|
421
|
+
<ClientOnly>
|
|
422
|
+
<div class="md-frame">
|
|
423
|
+
<component :is="Editor" v-if="Editor" v-model="value" />
|
|
424
|
+
<div v-else class="md-frame__loading">Loading editor…</div>
|
|
425
|
+
</div>
|
|
426
|
+
<p class="md-hint">
|
|
427
|
+
The <code>---</code> YAML block at the top renders as muted, italic
|
|
428
|
+
<code>key: value</code> lines — not a collapsed heading. Edit the body and watch the
|
|
429
|
+
raw <code>v-model</code>: the frontmatter is preserved untouched on save.
|
|
430
|
+
</p>
|
|
431
|
+
<details class="md-output">
|
|
432
|
+
<summary>Raw markdown (v-model)</summary>
|
|
433
|
+
<pre>{{ value }}</pre>
|
|
434
|
+
</details>
|
|
435
|
+
</ClientOnly>
|
|
436
|
+
</template>
|
|
437
|
+
|
|
438
|
+
<script setup lang="ts">
|
|
439
|
+
import { onMounted, ref, shallowRef, type Component } from 'vue';
|
|
440
|
+
|
|
441
|
+
const value = ref(`---
|
|
442
|
+
title: Release notes
|
|
443
|
+
author: Jane Doe
|
|
444
|
+
tags:
|
|
445
|
+
- editor
|
|
446
|
+
- markdown
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
# Heading
|
|
450
|
+
|
|
451
|
+
Body text. Try editing me — the metadata above stays put.
|
|
452
|
+
`);
|
|
453
|
+
|
|
454
|
+
const Editor = shallowRef<Component | null>(null);
|
|
455
|
+
|
|
456
|
+
onMounted(async () => {
|
|
457
|
+
const mod = await import('@cocoar/vue-markdown-editor');
|
|
458
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
459
|
+
});
|
|
460
|
+
</script>
|
|
461
|
+
|
|
462
|
+
<style scoped>
|
|
463
|
+
.md-frame {
|
|
464
|
+
height: 360px;
|
|
465
|
+
border: 1px solid var(--coar-border-neutral);
|
|
466
|
+
border-radius: var(--coar-radius-xl);
|
|
467
|
+
overflow: hidden;
|
|
468
|
+
display: flex;
|
|
469
|
+
flex-direction: column;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.md-frame__loading {
|
|
473
|
+
padding: 24px;
|
|
474
|
+
text-align: center;
|
|
475
|
+
color: var(--coar-text-neutral-tertiary);
|
|
476
|
+
font-size: 13px;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
.md-hint {
|
|
480
|
+
margin-top: 8px;
|
|
481
|
+
font-size: 13px;
|
|
482
|
+
color: var(--coar-text-neutral-secondary);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
.md-output { margin-top: 12px; }
|
|
486
|
+
.md-output summary {
|
|
487
|
+
cursor: pointer;
|
|
488
|
+
font-size: 13px;
|
|
489
|
+
font-weight: 600;
|
|
490
|
+
}
|
|
491
|
+
.md-output pre {
|
|
492
|
+
margin-top: 8px;
|
|
493
|
+
padding: 12px;
|
|
494
|
+
background: var(--coar-background-neutral-secondary);
|
|
495
|
+
border-radius: var(--coar-radius-xl);
|
|
496
|
+
font-size: 12px;
|
|
497
|
+
max-height: 220px;
|
|
498
|
+
overflow: auto;
|
|
499
|
+
white-space: pre-wrap;
|
|
500
|
+
}
|
|
501
|
+
</style>
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
The frontmatter is an **atomic block**: display-only in the rendered editor (muted + italic, like disabled text), selectable/deletable as a unit, and — crucially — it **round-trips**. The raw YAML is preserved verbatim, so `v-model` keeps emitting the `---` … `---` block untouched while you edit the body. To **edit** the YAML values, switch to [Source view](#source-view-raw-markdown).
|
|
505
|
+
|
|
506
|
+
> **Info: Parsing & nesting**
|
|
507
|
+
>
|
|
508
|
+
> Detection is powered by [`remark-frontmatter`](https://github.com/remarkjs/remark-frontmatter) on Milkdown's shared remark instance (and the matching parse path in `@cocoar/vue-markdown-core`). Only a YAML block at the very **top** of the document is treated as frontmatter — a `---` in the middle stays a horizontal rule. Malformed YAML falls back to showing the raw text in the card rather than collapsing.
|
|
509
|
+
|
|
510
|
+
## Source view (raw Markdown)
|
|
511
|
+
|
|
512
|
+
Set `source-toggle` to add a **Rendered ↔ Source** switch. In Source mode the entire document — body **and** the frontmatter YAML — is editable as raw Markdown in a `<textarea>`; switching back re-parses and re-renders it.
|
|
513
|
+
|
|
514
|
+
```vue
|
|
515
|
+
<CoarMarkdownEditor v-model="value" source-toggle toolbar-mode="fixed" />
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
**Demo — `markdown-editor/demos/MarkdownEditorSourceToggle.vue`**
|
|
519
|
+
|
|
520
|
+
```vue
|
|
521
|
+
<template>
|
|
522
|
+
<ClientOnly>
|
|
523
|
+
<div class="md-frame">
|
|
524
|
+
<component
|
|
525
|
+
:is="Editor"
|
|
526
|
+
v-if="Editor"
|
|
527
|
+
v-model="value"
|
|
528
|
+
source-toggle
|
|
529
|
+
toolbar-mode="fixed"
|
|
530
|
+
/>
|
|
531
|
+
<div v-else class="md-frame__loading">Loading editor…</div>
|
|
532
|
+
</div>
|
|
533
|
+
<p class="md-hint">
|
|
534
|
+
Use the <strong>Source</strong> button at the top of the toolbar. In Source mode you
|
|
535
|
+
edit the whole Markdown document as raw text — including the frontmatter YAML —
|
|
536
|
+
and the toolbar's <strong>Rendered</strong> button switches back.
|
|
537
|
+
</p>
|
|
538
|
+
<details class="md-output">
|
|
539
|
+
<summary>Raw markdown (v-model)</summary>
|
|
540
|
+
<pre>{{ value }}</pre>
|
|
541
|
+
</details>
|
|
542
|
+
</ClientOnly>
|
|
543
|
+
</template>
|
|
544
|
+
|
|
545
|
+
<script setup lang="ts">
|
|
546
|
+
import { onMounted, ref, shallowRef, type Component } from 'vue';
|
|
547
|
+
|
|
548
|
+
const value = ref(`---
|
|
549
|
+
title: Release notes
|
|
550
|
+
status: draft
|
|
551
|
+
---
|
|
552
|
+
|
|
553
|
+
# Heading
|
|
554
|
+
|
|
555
|
+
Some **body** text. Flip to *Source* to edit the raw Markdown
|
|
556
|
+
(and the YAML above) directly.
|
|
557
|
+
`);
|
|
558
|
+
|
|
559
|
+
const Editor = shallowRef<Component | null>(null);
|
|
560
|
+
|
|
561
|
+
onMounted(async () => {
|
|
562
|
+
const mod = await import('@cocoar/vue-markdown-editor');
|
|
563
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
564
|
+
});
|
|
565
|
+
</script>
|
|
566
|
+
|
|
567
|
+
<style scoped>
|
|
568
|
+
.md-frame {
|
|
569
|
+
height: 360px;
|
|
570
|
+
border: 1px solid var(--coar-border-neutral);
|
|
571
|
+
border-radius: var(--coar-radius-xl);
|
|
572
|
+
overflow: hidden;
|
|
573
|
+
display: flex;
|
|
574
|
+
flex-direction: column;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.md-frame__loading {
|
|
578
|
+
padding: 24px;
|
|
579
|
+
text-align: center;
|
|
580
|
+
color: var(--coar-text-neutral-tertiary);
|
|
581
|
+
font-size: 13px;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
.md-hint {
|
|
585
|
+
margin-top: 8px;
|
|
586
|
+
font-size: 13px;
|
|
587
|
+
color: var(--coar-text-neutral-secondary);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
.md-output { margin-top: 12px; }
|
|
591
|
+
.md-output summary {
|
|
592
|
+
cursor: pointer;
|
|
593
|
+
font-size: 13px;
|
|
594
|
+
font-weight: 600;
|
|
595
|
+
}
|
|
596
|
+
.md-output pre {
|
|
597
|
+
margin-top: 8px;
|
|
598
|
+
padding: 12px;
|
|
599
|
+
background: var(--coar-background-neutral-secondary);
|
|
600
|
+
border-radius: var(--coar-radius-xl);
|
|
601
|
+
font-size: 12px;
|
|
602
|
+
max-height: 220px;
|
|
603
|
+
overflow: auto;
|
|
604
|
+
white-space: pre-wrap;
|
|
605
|
+
}
|
|
606
|
+
</style>
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
This is the way to hand-edit frontmatter, fix up exact Markdown, or paste raw content. The toggle is **off by default** — without `source-toggle` the editor is WYSIWYG-only with no extra chrome.
|
|
610
|
+
|
|
611
|
+
> **Info: Where the toggle lives**
|
|
612
|
+
>
|
|
613
|
+
> With a **fixed** sidebar toolbar (`toolbar-mode` `'fixed'` / `'both'`) the toggle is the **first item in the sidebar** — and in Source mode the sidebar collapses to just that toggle (the formatting buttons act on the hidden rich editor, so they're hidden). With the default **floating** toolbar there's no persistent toolbar, so a small toggle button appears in the editor's top-right corner instead.
|
|
614
|
+
|
|
615
|
+
> **Info: Behaviour**
|
|
616
|
+
>
|
|
617
|
+
> The rich editor stays mounted in Source mode (just hidden), so switching is cheap and the toolbar stays put. `readonly` / `disabled` and the `CoarFormField` wiring carry over to the Source `<textarea>`. Switching back re-seeds the rich editor from the current value, so raw edits (incl. frontmatter) are picked up — the rich editor's undo history resets across a mode switch.
|
|
618
|
+
|
|
619
|
+
## Images
|
|
620
|
+
|
|
621
|
+
Images round-trip as standard Markdown — `` — so anything you paste from another CMS (a WordPress export, say) renders as-is in both the editor and `<CoarMarkdown>`.
|
|
622
|
+
|
|
623
|
+
There are three ways to add one:
|
|
624
|
+
|
|
625
|
+
- **Insert by URL** — the **Insert Image** button in the sidebar opens a small dialog for `url` / `alt` / `title`. (Like the table and code-block buttons, it lives in the **sidebar**, so use `toolbar-mode="fixed"` or `"both"`.)
|
|
626
|
+
- **Paste** an image from the clipboard (e.g. a screenshot).
|
|
627
|
+
- **Drag & drop** an image file into the writing area.
|
|
628
|
+
|
|
629
|
+
Paste and drop require an `upload-image` callback. It receives the dropped/pasted `File`, stores it wherever you like, and resolves with the resulting `url` (plus optional `alt`). A spinner placeholder is shown at the insertion point until it resolves, then is replaced by the image. Without the callback, image files fall through to the browser's default handling.
|
|
630
|
+
|
|
631
|
+
```vue
|
|
632
|
+
<CoarMarkdownEditor v-model="value" toolbar-mode="both" :upload-image="uploadImage" />
|
|
633
|
+
|
|
634
|
+
<script setup lang="ts">
|
|
635
|
+
async function uploadImage(file: File) {
|
|
636
|
+
const url = await myAssetService.upload(file); // your storage
|
|
637
|
+
return { url, alt: file.name };
|
|
638
|
+
}
|
|
639
|
+
</script>
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
**Demo — `markdown-editor/demos/MarkdownEditorImages.vue`**
|
|
643
|
+
|
|
644
|
+
```vue
|
|
645
|
+
<template>
|
|
646
|
+
<ClientOnly>
|
|
647
|
+
<div class="md-frame">
|
|
648
|
+
<component
|
|
649
|
+
:is="Editor"
|
|
650
|
+
v-if="Editor"
|
|
651
|
+
v-model="value"
|
|
652
|
+
toolbar-mode="both"
|
|
653
|
+
:upload-image="uploadImage"
|
|
654
|
+
/>
|
|
655
|
+
<div v-else class="md-frame__loading">Loading editor…</div>
|
|
656
|
+
</div>
|
|
657
|
+
<p class="md-hint">
|
|
658
|
+
Click the <strong>Insert Image</strong> button in the sidebar to add one by URL,
|
|
659
|
+
or <strong>paste / drag & drop</strong> an image file straight into the editor.
|
|
660
|
+
</p>
|
|
661
|
+
<details class="md-output">
|
|
662
|
+
<summary>Raw markdown (v-model)</summary>
|
|
663
|
+
<pre>{{ value }}</pre>
|
|
664
|
+
</details>
|
|
665
|
+
</ClientOnly>
|
|
666
|
+
</template>
|
|
667
|
+
|
|
668
|
+
<script setup lang="ts">
|
|
669
|
+
import { onMounted, ref, shallowRef, type Component } from 'vue';
|
|
670
|
+
|
|
671
|
+
const value = ref(`# Images
|
|
672
|
+
|
|
673
|
+
Markdown images round-trip as \`\`:
|
|
674
|
+
|
|
675
|
+

|
|
676
|
+
|
|
677
|
+
Try inserting your own below.
|
|
678
|
+
`);
|
|
679
|
+
|
|
680
|
+
const Editor = shallowRef<Component | null>(null);
|
|
681
|
+
|
|
682
|
+
onMounted(async () => {
|
|
683
|
+
const mod = await import('@cocoar/vue-markdown-editor');
|
|
684
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Demo uploader: reads the file as a base64 data URL so the demo works fully
|
|
689
|
+
* offline with no backend. A real app would POST the file to its asset service
|
|
690
|
+
* and return the hosted URL instead.
|
|
691
|
+
*/
|
|
692
|
+
function uploadImage(file: File): Promise<{ url: string; alt?: string }> {
|
|
693
|
+
return new Promise((resolve, reject) => {
|
|
694
|
+
const reader = new FileReader();
|
|
695
|
+
reader.onload = () => resolve({ url: reader.result as string, alt: file.name });
|
|
696
|
+
reader.onerror = () => reject(reader.error);
|
|
697
|
+
reader.readAsDataURL(file);
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
</script>
|
|
701
|
+
|
|
702
|
+
<style scoped>
|
|
703
|
+
.md-frame {
|
|
704
|
+
height: 420px;
|
|
705
|
+
border: 1px solid var(--coar-border-neutral);
|
|
706
|
+
border-radius: var(--coar-radius-xl);
|
|
707
|
+
overflow: hidden;
|
|
708
|
+
display: flex;
|
|
709
|
+
flex-direction: column;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
.md-frame__loading {
|
|
713
|
+
padding: 24px;
|
|
714
|
+
text-align: center;
|
|
715
|
+
color: var(--coar-text-neutral-tertiary);
|
|
716
|
+
font-size: 13px;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
.md-hint {
|
|
720
|
+
margin-top: 10px;
|
|
721
|
+
font-size: 13px;
|
|
722
|
+
color: var(--coar-text-neutral-secondary);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.md-output { margin-top: 12px; }
|
|
726
|
+
.md-output summary {
|
|
727
|
+
cursor: pointer;
|
|
728
|
+
font-size: 13px;
|
|
729
|
+
font-weight: 600;
|
|
730
|
+
}
|
|
731
|
+
.md-output pre {
|
|
732
|
+
margin-top: 8px;
|
|
733
|
+
padding: 12px;
|
|
734
|
+
background: var(--coar-background-neutral-secondary);
|
|
735
|
+
border-radius: var(--coar-radius-xl);
|
|
736
|
+
font-size: 12px;
|
|
737
|
+
max-height: 200px;
|
|
738
|
+
overflow: auto;
|
|
739
|
+
white-space: pre-wrap;
|
|
740
|
+
}
|
|
741
|
+
</style>
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
### Custom image source (`pickImage`)
|
|
745
|
+
|
|
746
|
+
To wire the **Insert Image** button to your own asset library or gallery, pass a `pickImage` callback. When set, it **replaces** the built-in URL dialog: clicking the button calls your callback with a context bound to the cursor position — `insertImage(...)` plus the `selectedText` (a handy default for `alt`). Open your own modal, then call `ctx.insertImage(...)` for each chosen image. The modal can stay open and insert several; the editor keeps ownership of cursor handling and the Markdown round-trip, so you never touch ProseMirror.
|
|
747
|
+
|
|
748
|
+
```vue
|
|
749
|
+
<CoarMarkdownEditor v-model="value" toolbar-mode="both" :pick-image="openGallery" />
|
|
750
|
+
|
|
751
|
+
<script setup lang="ts">
|
|
752
|
+
function openGallery(ctx) {
|
|
753
|
+
myGalleryModal.open({
|
|
754
|
+
defaultAlt: ctx.selectedText,
|
|
755
|
+
onPick: (asset) => ctx.insertImage({ url: asset.url, alt: asset.title }),
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
</script>
|
|
759
|
+
```
|
|
760
|
+
|
|
761
|
+
`pickImage` (button → your picker) and `uploadImage` (paste / drop) are orthogonal and compose — wire both for a full gallery-plus-paste experience.
|
|
762
|
+
|
|
763
|
+
**Demo — `markdown-editor/demos/MarkdownEditorImageGallery.vue`**
|
|
764
|
+
|
|
765
|
+
```vue
|
|
766
|
+
<template>
|
|
767
|
+
<ClientOnly>
|
|
768
|
+
<div class="md-frame">
|
|
769
|
+
<component
|
|
770
|
+
:is="Editor"
|
|
771
|
+
v-if="Editor"
|
|
772
|
+
v-model="value"
|
|
773
|
+
toolbar-mode="both"
|
|
774
|
+
:pick-image="openGallery"
|
|
775
|
+
/>
|
|
776
|
+
<div v-else class="md-frame__loading">Loading editor…</div>
|
|
777
|
+
</div>
|
|
778
|
+
<p class="md-hint">
|
|
779
|
+
Click <strong>Insert Image</strong> in the sidebar — instead of the built-in URL dialog,
|
|
780
|
+
a custom “gallery” opens. Pick one or more; the modal stays open so you can insert several.
|
|
781
|
+
</p>
|
|
782
|
+
|
|
783
|
+
<!-- The consumer's own gallery modal — entirely app-owned. -->
|
|
784
|
+
<div v-if="galleryOpen" class="gallery-backdrop" @click.self="closeGallery">
|
|
785
|
+
<div class="gallery">
|
|
786
|
+
<header class="gallery__head">
|
|
787
|
+
<strong>Tellify gallery</strong>
|
|
788
|
+
<button class="gallery__x" @click="closeGallery" aria-label="Close">✕</button>
|
|
789
|
+
</header>
|
|
790
|
+
<div class="gallery__grid">
|
|
791
|
+
<button
|
|
792
|
+
v-for="img in assets"
|
|
793
|
+
:key="img.url"
|
|
794
|
+
class="gallery__item"
|
|
795
|
+
@click="pick(img)"
|
|
796
|
+
>
|
|
797
|
+
<img :src="img.url" :alt="img.alt" />
|
|
798
|
+
<span>{{ img.alt }}</span>
|
|
799
|
+
</button>
|
|
800
|
+
</div>
|
|
801
|
+
<footer class="gallery__foot">
|
|
802
|
+
<label class="gallery__upload">
|
|
803
|
+
Upload…
|
|
804
|
+
<input type="file" accept="image/*" hidden @change="onUpload" />
|
|
805
|
+
</label>
|
|
806
|
+
<button class="gallery__done" @click="closeGallery">Done</button>
|
|
807
|
+
</footer>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
|
|
811
|
+
<details class="md-output">
|
|
812
|
+
<summary>Raw markdown (v-model)</summary>
|
|
813
|
+
<pre>{{ value }}</pre>
|
|
814
|
+
</details>
|
|
815
|
+
</ClientOnly>
|
|
816
|
+
</template>
|
|
817
|
+
|
|
818
|
+
<script setup lang="ts">
|
|
819
|
+
import { onMounted, ref, shallowRef, type Component } from 'vue';
|
|
820
|
+
|
|
821
|
+
interface Asset { url: string; alt: string }
|
|
822
|
+
// Stand-in for what a real gallery API would return.
|
|
823
|
+
const assets: Asset[] = [
|
|
824
|
+
{ url: 'https://picsum.photos/seed/alpha/320/200', alt: 'Alpha' },
|
|
825
|
+
{ url: 'https://picsum.photos/seed/bravo/320/200', alt: 'Bravo' },
|
|
826
|
+
{ url: 'https://picsum.photos/seed/charlie/320/200', alt: 'Charlie' },
|
|
827
|
+
{ url: 'https://picsum.photos/seed/delta/320/200', alt: 'Delta' },
|
|
828
|
+
];
|
|
829
|
+
|
|
830
|
+
const value = ref(`# Gallery picker
|
|
831
|
+
|
|
832
|
+
Click **Insert Image** to choose from the gallery.
|
|
833
|
+
`);
|
|
834
|
+
|
|
835
|
+
const Editor = shallowRef<Component | null>(null);
|
|
836
|
+
onMounted(async () => {
|
|
837
|
+
const mod = await import('@cocoar/vue-markdown-editor');
|
|
838
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// pickImage hands us a context bound to the cursor. We stash it, open our
|
|
842
|
+
// modal, and call ctx.insertImage(...) for each chosen asset — the modal can
|
|
843
|
+
// stay open and insert several.
|
|
844
|
+
type PickContext = { insertImage: (img: { url: string; alt?: string }) => void; selectedText: string };
|
|
845
|
+
const galleryOpen = ref(false);
|
|
846
|
+
let ctx: PickContext | null = null;
|
|
847
|
+
|
|
848
|
+
function openGallery(c: PickContext) {
|
|
849
|
+
ctx = c;
|
|
850
|
+
galleryOpen.value = true;
|
|
851
|
+
}
|
|
852
|
+
function closeGallery() {
|
|
853
|
+
galleryOpen.value = false;
|
|
854
|
+
ctx = null;
|
|
855
|
+
}
|
|
856
|
+
function pick(img: Asset) {
|
|
857
|
+
ctx?.insertImage({ url: img.url, alt: img.alt });
|
|
858
|
+
}
|
|
859
|
+
function onUpload(e: Event) {
|
|
860
|
+
const file = (e.target as HTMLInputElement).files?.[0];
|
|
861
|
+
if (!file) return;
|
|
862
|
+
const reader = new FileReader();
|
|
863
|
+
reader.onload = () => ctx?.insertImage({ url: reader.result as string, alt: file.name });
|
|
864
|
+
reader.readAsDataURL(file);
|
|
865
|
+
}
|
|
866
|
+
</script>
|
|
867
|
+
|
|
868
|
+
<style scoped>
|
|
869
|
+
.md-frame {
|
|
870
|
+
height: 420px;
|
|
871
|
+
border: 1px solid var(--coar-border-neutral);
|
|
872
|
+
border-radius: var(--coar-radius-xl);
|
|
873
|
+
overflow: hidden;
|
|
874
|
+
display: flex;
|
|
875
|
+
flex-direction: column;
|
|
876
|
+
}
|
|
877
|
+
.md-frame__loading { padding: 24px; text-align: center; color: var(--coar-text-neutral-tertiary); font-size: 13px; }
|
|
878
|
+
.md-hint { margin-top: 10px; font-size: 13px; color: var(--coar-text-neutral-secondary); }
|
|
879
|
+
|
|
880
|
+
.gallery-backdrop {
|
|
881
|
+
position: fixed; inset: 0; z-index: 50;
|
|
882
|
+
background: rgba(0, 0, 0, 0.45);
|
|
883
|
+
display: flex; align-items: center; justify-content: center;
|
|
884
|
+
}
|
|
885
|
+
.gallery {
|
|
886
|
+
width: min(560px, 92vw);
|
|
887
|
+
background: var(--coar-background-neutral-primary);
|
|
888
|
+
border-radius: var(--coar-radius-xl);
|
|
889
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
|
890
|
+
overflow: hidden;
|
|
891
|
+
}
|
|
892
|
+
.gallery__head, .gallery__foot {
|
|
893
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
894
|
+
padding: 12px 16px;
|
|
895
|
+
}
|
|
896
|
+
.gallery__head { border-bottom: 1px solid var(--coar-border-neutral); }
|
|
897
|
+
.gallery__foot { border-top: 1px solid var(--coar-border-neutral); }
|
|
898
|
+
.gallery__x { background: none; border: none; cursor: pointer; font-size: 16px; }
|
|
899
|
+
.gallery__grid {
|
|
900
|
+
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; padding: 16px;
|
|
901
|
+
}
|
|
902
|
+
.gallery__item {
|
|
903
|
+
display: flex; flex-direction: column; gap: 4px; padding: 0;
|
|
904
|
+
background: none; border: 1px solid var(--coar-border-neutral);
|
|
905
|
+
border-radius: var(--coar-radius-l); overflow: hidden; cursor: pointer;
|
|
906
|
+
font-size: 12px;
|
|
907
|
+
}
|
|
908
|
+
.gallery__item:hover { border-color: var(--coar-border-interactive, #888); }
|
|
909
|
+
.gallery__item img { width: 100%; height: 100px; object-fit: cover; display: block; }
|
|
910
|
+
.gallery__item span { padding: 4px 8px; }
|
|
911
|
+
.gallery__upload { cursor: pointer; font-size: 13px; text-decoration: underline; }
|
|
912
|
+
.gallery__done {
|
|
913
|
+
background: var(--coar-background-interactive-bold, #2563eb); color: #fff;
|
|
914
|
+
border: none; border-radius: var(--coar-radius-l); padding: 6px 14px; cursor: pointer;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
.md-output { margin-top: 12px; }
|
|
918
|
+
.md-output summary { cursor: pointer; font-size: 13px; font-weight: 600; }
|
|
919
|
+
.md-output pre {
|
|
920
|
+
margin-top: 8px; padding: 12px;
|
|
921
|
+
background: var(--coar-background-neutral-secondary);
|
|
922
|
+
border-radius: var(--coar-radius-xl); font-size: 12px;
|
|
923
|
+
max-height: 200px; overflow: auto; white-space: pre-wrap;
|
|
924
|
+
}
|
|
925
|
+
</style>
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
> **Info: Resize / alignment / captions**
|
|
929
|
+
>
|
|
930
|
+
> Width, alignment, and captions aren't part of standard Markdown, so they're not supported yet — a richer image block (a separate slice) is planned. Today an image is the plain ``.
|
|
931
|
+
|
|
932
|
+
## Tables
|
|
933
|
+
|
|
934
|
+
GFM tables are portable (they render on GitHub, in `swift-markdown-ui`, etc.), so they're available in the `'gfm'` and `'cocoar'` [flavors](#flavors-portability). The editor offers a full set of table operations:
|
|
935
|
+
|
|
936
|
+
**Create** — two ways:
|
|
937
|
+
|
|
938
|
+
- The **Insert Table** sidebar button opens a small **grid size picker** — hover (or tap) to choose `cols × rows`, then click to insert.
|
|
939
|
+
- Type **`|3x4|`** followed by a space anywhere — a GFM input rule turns it into a 3-column × 4-row table. This needs no toolbar, so it's the way to create a table in the default `floating` mode.
|
|
940
|
+
|
|
941
|
+
**Edit** — two ways:
|
|
942
|
+
|
|
943
|
+
- **Hover edge-handles** (Notion/Word-style) — point at any edge of a table and grips appear along all four sides (a segment per column on top & bottom, per row on left & right). Hovering a grip highlights the whole column/row; **clicking** it opens a menu to **insert before / after** or **delete**, and **dragging** it **reorders** the column/row (with a live drop indicator).
|
|
944
|
+
- **In-cell toolbar** — with the cursor inside a cell, the floating toolbar (and the sidebar in `fixed`/`both` mode) offers insert row/column, **column alignment** (left / center / right, applied to the whole column — round-trips as GFM `:--` / `:-:` / `--:`, active alignment highlighted), **delete cell** and **delete table**.
|
|
945
|
+
|
|
946
|
+
> **Info: How the handles work**
|
|
947
|
+
>
|
|
948
|
+
> The handles are geometry-driven — they measure the hovered table's cell rectangles and render fixed-position grips, rather than reacting to ProseMirror's `CellSelection` (which doesn't fire `selectionchange`). Clicking a grip targets that column/row by position, so it works on any table without needing a cursor inside it first.
|
|
949
|
+
|
|
950
|
+
## Code blocks — view / edit toggle
|
|
951
|
+
|
|
952
|
+
Code blocks have a richer UX than the rest of the editor. When the cursor is **outside** a code block it renders as `CoarCodeBlock` with full Prism syntax highlighting — same component, same look as `<CoarMarkdown>` produces in the viewer. When the cursor moves **inside** the block it switches to plain editable mode plus a language selector at the top.
|
|
953
|
+
|
|
954
|
+
| State | What renders | Why |
|
|
955
|
+
| ------------------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
|
956
|
+
| Cursor **outside** | `CoarCodeBlock` (Prism-highlighted, copy button, language label) | Read-mode aesthetic — matches the viewer |
|
|
957
|
+
| Cursor **inside** | Plain editable text + `CoarSelect` for the language | Editing on top of Prism-highlighted DOM is fragile (cursor jumps, IME issues). Plain text avoids that. |
|
|
958
|
+
|
|
959
|
+
Switching directions:
|
|
960
|
+
|
|
961
|
+
- **Render → edit**: hover the code block to reveal a small **Edit** button (top-right), or simply click into the text via PM's natural cursor placement
|
|
962
|
+
- **Edit → render**: click anywhere outside the code block. PM's selection moves out → the NodeView swaps back automatically
|
|
963
|
+
|
|
964
|
+
Supported languages match what `CoarCodeBlock` ships with: `typescript`, `javascript`, `json`, `css`, `scss`, `html`, `bash`, plus `''` (Plain text — no highlighting). The language string is persisted exactly as picked into the markdown fence (` ```json `).
|
|
965
|
+
|
|
966
|
+
> **Tip: Custom code-block renderer**
|
|
967
|
+
>
|
|
968
|
+
> The render-mode component is the registry's `codeBlock` slot. Override it in `provide(MARKDOWN_RENDERERS_KEY, { ...defaults, codeBlock: MyCustom })` and the editor's render mode picks up the same custom component without any extra wiring.
|
|
969
|
+
|
|
970
|
+
## Form Integration
|
|
971
|
+
|
|
972
|
+
`CoarMarkdownEditor` is a full citizen of the Cocoar form ecosystem. Drop it inside `CoarFormField` and the label, error message, `aria-describedby` wiring, and `disabled` state propagate automatically — the same way `CoarTextInput`, `CoarSelect`, and `CoarScriptEditor` behave.
|
|
973
|
+
|
|
974
|
+
**Demo — `markdown-editor/demos/MarkdownEditorInForm.vue`**
|
|
975
|
+
|
|
976
|
+
```vue
|
|
977
|
+
<template>
|
|
978
|
+
<ClientOnly>
|
|
979
|
+
<div v-if="Editor && FormField && TextInput && Button" class="form-demo">
|
|
980
|
+
<component :is="FormField" label="Title" :error="titleError" required>
|
|
981
|
+
<component :is="TextInput" v-model="form.title" placeholder="My note" />
|
|
982
|
+
</component>
|
|
983
|
+
|
|
984
|
+
<component
|
|
985
|
+
:is="FormField"
|
|
986
|
+
label="Body"
|
|
987
|
+
:error="bodyError"
|
|
988
|
+
:disabled="locked"
|
|
989
|
+
hint="Markdown — select text to format."
|
|
990
|
+
required
|
|
991
|
+
>
|
|
992
|
+
<div class="md-frame">
|
|
993
|
+
<component :is="Editor" v-model="form.body" />
|
|
994
|
+
</div>
|
|
995
|
+
</component>
|
|
996
|
+
|
|
997
|
+
<div class="row">
|
|
998
|
+
<component :is="Button" type="primary" @clicked="onSubmit">Save</component>
|
|
999
|
+
<component :is="Button" @clicked="onReset">Reset</component>
|
|
1000
|
+
<label class="lock">
|
|
1001
|
+
<input v-model="locked" type="checkbox" /> lock (disabled)
|
|
1002
|
+
</label>
|
|
1003
|
+
</div>
|
|
1004
|
+
|
|
1005
|
+
<pre class="preview">{{ preview }}</pre>
|
|
1006
|
+
</div>
|
|
1007
|
+
<div v-else class="loading">Loading editor…</div>
|
|
1008
|
+
</ClientOnly>
|
|
1009
|
+
</template>
|
|
1010
|
+
|
|
1011
|
+
<script setup lang="ts">
|
|
1012
|
+
import { computed, onMounted, reactive, ref, shallowRef, type Component } from 'vue';
|
|
1013
|
+
|
|
1014
|
+
const form = reactive({
|
|
1015
|
+
title: '',
|
|
1016
|
+
body: '',
|
|
1017
|
+
});
|
|
1018
|
+
const locked = ref(false);
|
|
1019
|
+
|
|
1020
|
+
const titleError = computed(() => (form.title.length === 0 ? 'Required.' : ''));
|
|
1021
|
+
const bodyError = computed(() =>
|
|
1022
|
+
form.body.trim().length === 0 ? 'Body cannot be empty.' : '',
|
|
1023
|
+
);
|
|
1024
|
+
const preview = computed(() => JSON.stringify(form, null, 2));
|
|
1025
|
+
|
|
1026
|
+
function onSubmit() {
|
|
1027
|
+
if (titleError.value || bodyError.value) return;
|
|
1028
|
+
// eslint-disable-next-line no-console
|
|
1029
|
+
console.log('submit', form);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function onReset() {
|
|
1033
|
+
form.title = '';
|
|
1034
|
+
form.body = '';
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const Editor = shallowRef<Component | null>(null);
|
|
1038
|
+
const FormField = shallowRef<Component | null>(null);
|
|
1039
|
+
const TextInput = shallowRef<Component | null>(null);
|
|
1040
|
+
const Button = shallowRef<Component | null>(null);
|
|
1041
|
+
|
|
1042
|
+
onMounted(async () => {
|
|
1043
|
+
const [mod, ui] = await Promise.all([
|
|
1044
|
+
import('@cocoar/vue-markdown-editor'),
|
|
1045
|
+
import('@cocoar/vue-ui'),
|
|
1046
|
+
]);
|
|
1047
|
+
Editor.value = mod.CoarMarkdownEditor;
|
|
1048
|
+
FormField.value = ui.CoarFormField;
|
|
1049
|
+
TextInput.value = ui.CoarTextInput;
|
|
1050
|
+
Button.value = ui.CoarButton;
|
|
1051
|
+
});
|
|
1052
|
+
</script>
|
|
1053
|
+
|
|
1054
|
+
<style scoped>
|
|
1055
|
+
.form-demo {
|
|
1056
|
+
display: flex;
|
|
1057
|
+
flex-direction: column;
|
|
1058
|
+
gap: 16px;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
.md-frame {
|
|
1062
|
+
height: 220px;
|
|
1063
|
+
border: 1px solid var(--coar-border-neutral);
|
|
1064
|
+
border-radius: var(--coar-radius-xl);
|
|
1065
|
+
overflow: hidden;
|
|
1066
|
+
display: flex;
|
|
1067
|
+
flex-direction: column;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
.row {
|
|
1071
|
+
display: flex;
|
|
1072
|
+
align-items: center;
|
|
1073
|
+
gap: 8px;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
.lock {
|
|
1077
|
+
display: inline-flex;
|
|
1078
|
+
align-items: center;
|
|
1079
|
+
gap: 4px;
|
|
1080
|
+
margin-left: 12px;
|
|
1081
|
+
font-size: 13px;
|
|
1082
|
+
cursor: pointer;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
.preview {
|
|
1086
|
+
background: var(--coar-background-neutral-secondary);
|
|
1087
|
+
border: 1px solid var(--coar-border-neutral);
|
|
1088
|
+
border-radius: var(--coar-radius-xl);
|
|
1089
|
+
padding: 12px;
|
|
1090
|
+
font-size: 12px;
|
|
1091
|
+
color: var(--coar-text-neutral-secondary);
|
|
1092
|
+
overflow: auto;
|
|
1093
|
+
margin: 0;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
.loading {
|
|
1097
|
+
padding: 24px;
|
|
1098
|
+
text-align: center;
|
|
1099
|
+
color: var(--coar-text-neutral-tertiary);
|
|
1100
|
+
font-size: 13px;
|
|
1101
|
+
}
|
|
1102
|
+
</style>
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
```vue
|
|
1106
|
+
<template>
|
|
1107
|
+
<CoarFormField label="Body" :error="bodyError" hint="Markdown" required>
|
|
1108
|
+
<CoarMarkdownEditor v-model="form.body" />
|
|
1109
|
+
</CoarFormField>
|
|
1110
|
+
</template>
|
|
1111
|
+
|
|
1112
|
+
<script setup lang="ts">
|
|
1113
|
+
import { computed, reactive } from 'vue';
|
|
1114
|
+
import { CoarFormField } from '@cocoar/vue-ui';
|
|
1115
|
+
import { CoarMarkdownEditor } from '@cocoar/vue-markdown-editor';
|
|
1116
|
+
|
|
1117
|
+
const form = reactive({ body: '' });
|
|
1118
|
+
const bodyError = computed(() => (form.body.trim().length === 0 ? 'Body cannot be empty.' : ''));
|
|
1119
|
+
</script>
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
What gets auto-wired from the surrounding `<CoarFormField>`:
|
|
1123
|
+
|
|
1124
|
+
| Form-field state | Effect on the editor |
|
|
1125
|
+
| ---------------- | ----------------------------------------------------------------------------------------------------------- | --- | --------------------------------------------------------------------------------------- |
|
|
1126
|
+
| `id` | Set as the editor wrapper's `id` (so `<label for="...">` association works) |
|
|
1127
|
+
| `error` | Sets `aria-invalid="true"` and applies the error outline |
|
|
1128
|
+
| `disabled` | Combined with the editor's own `readonly` prop — `disabled | | readonly`controls editability.`disabled` also dims the editor and blocks pointer events |
|
|
1129
|
+
| `messageId` | Set as `aria-describedby` so screen readers announce the form-field's error/hint when the editor is focused |
|
|
1130
|
+
|
|
1131
|
+
You can also pass these props directly without `CoarFormField` (`error`, `disabled`, `id`) — direct props win over the injected context.
|
|
1132
|
+
|
|
1133
|
+
## Text Color
|
|
1134
|
+
|
|
1135
|
+
Apply inline color to a selection. Click the **palette** button in the floating toolbar (or the sidebar item in fixed mode) to open the picker — pick a swatch from the 8-color palette or use the native browser color input for a custom hex value. The color persists to markdown as plain inline HTML so the document stays readable in any standard renderer:
|
|
1136
|
+
|
|
1137
|
+
```markdown
|
|
1138
|
+
The quick <span style="color: #dc2626">red</span> fox.
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
**Demo — `markdown-editor/demos/MarkdownEditorTextColor.vue`**
|
|
1142
|
+
|
|
1143
|
+
```vue
|
|
1144
|
+
<template>
|
|
1145
|
+
<ClientOnly>
|
|
1146
|
+
<div class="md-color-demo">
|
|
1147
|
+
<div class="md-color-demo__frame">
|
|
1148
|
+
<component :is="Editor" v-if="Editor" v-model="value" />
|
|
1149
|
+
<div v-else class="md-color-demo__loading">Loading editor…</div>
|
|
1150
|
+
</div>
|
|
1151
|
+
<div class="md-color-demo__viewer">
|
|
1152
|
+
<div class="md-color-demo__viewer-label">Rendered output (`@cocoar/vue-markdown`)</div>
|
|
1153
|
+
<component :is="Viewer" v-if="Viewer && doc" :doc="doc" />
|
|
1154
|
+
</div>
|
|
1155
|
+
</div>
|
|
1156
|
+
<details class="md-output">
|
|
1157
|
+
<summary>Raw markdown (v-model)</summary>
|
|
1158
|
+
<pre>{{ value }}</pre>
|
|
1159
|
+
</details>
|
|
1160
|
+
</ClientOnly>
|
|
1161
|
+
</template>
|
|
1162
|
+
|
|
1163
|
+
<script setup lang="ts">
|
|
1164
|
+
import { computed, onMounted, ref, shallowRef, type Component } from 'vue';
|
|
1165
|
+
|
|
1166
|
+
const value = ref(`# Text color round-trip
|
|
1167
|
+
|
|
1168
|
+
Select some text and click the **palette** button in the floating toolbar.
|
|
1169
|
+
Try a swatch, then the custom hex input, then re-select the colored text.
|
|
1170
|
+
|
|
1171
|
+
The wire format on disk is plain inline HTML:
|
|
1172
|
+
<span style="color: #dc2626">red</span>,
|
|
1173
|
+
<span style="color: #2563eb">blue</span>,
|
|
1174
|
+
<span style="color: rgb(22, 163, 74)">green via rgb()</span>.
|
|
1175
|
+
|
|
1176
|
+
Anything outside the whitelist (other CSS properties, \`url(...)\`, \`var()\`)
|
|
1177
|
+
is rejected by the sanitizer in both the editor and the viewer.
|
|
1178
|
+
`);
|
|
1179
|
+
|
|
1180
|
+
const Editor = shallowRef<Component | null>(null);
|
|
1181
|
+
const Viewer = shallowRef<Component | null>(null);
|
|
1182
|
+
const parse = shallowRef<((md: string) => unknown) | null>(null);
|
|
1183
|
+
|
|
1184
|
+
const doc = computed(() => (parse.value ? parse.value(value.value) : null));
|
|
1185
|
+
|
|
1186
|
+
onMounted(async () => {
|
|
1187
|
+
const [editor, viewer, core] = await Promise.all([
|
|
1188
|
+
import('@cocoar/vue-markdown-editor'),
|
|
1189
|
+
import('@cocoar/vue-markdown'),
|
|
1190
|
+
import('@cocoar/vue-markdown-core'),
|
|
1191
|
+
]);
|
|
1192
|
+
Editor.value = editor.CoarMarkdownEditor;
|
|
1193
|
+
Viewer.value = viewer.CoarMarkdown;
|
|
1194
|
+
parse.value = (md: string) => core.parse(md);
|
|
1195
|
+
});
|
|
1196
|
+
</script>
|
|
1197
|
+
|
|
1198
|
+
<style scoped>
|
|
1199
|
+
.md-color-demo {
|
|
1200
|
+
display: grid;
|
|
1201
|
+
grid-template-columns: 1fr 1fr;
|
|
1202
|
+
gap: var(--coar-spacing-m);
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
.md-color-demo__frame {
|
|
1206
|
+
height: 360px;
|
|
1207
|
+
border: 1px solid var(--coar-border-neutral);
|
|
1208
|
+
border-radius: var(--coar-radius-xl);
|
|
1209
|
+
overflow: hidden;
|
|
1210
|
+
display: flex;
|
|
1211
|
+
flex-direction: column;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
.md-color-demo__loading {
|
|
1215
|
+
padding: 24px;
|
|
1216
|
+
text-align: center;
|
|
1217
|
+
color: var(--coar-text-neutral-tertiary);
|
|
1218
|
+
font-size: 13px;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
.md-color-demo__viewer {
|
|
1222
|
+
height: 360px;
|
|
1223
|
+
border: 1px solid var(--coar-border-neutral);
|
|
1224
|
+
border-radius: var(--coar-radius-xl);
|
|
1225
|
+
overflow: auto;
|
|
1226
|
+
padding: var(--coar-spacing-m);
|
|
1227
|
+
background: var(--coar-background-neutral-primary);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
.md-color-demo__viewer-label {
|
|
1231
|
+
font-size: 12px;
|
|
1232
|
+
text-transform: uppercase;
|
|
1233
|
+
letter-spacing: 0.04em;
|
|
1234
|
+
color: var(--coar-text-neutral-tertiary);
|
|
1235
|
+
margin-bottom: var(--coar-spacing-s);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
.md-output { margin-top: 12px; }
|
|
1239
|
+
.md-output summary {
|
|
1240
|
+
cursor: pointer;
|
|
1241
|
+
font-size: 13px;
|
|
1242
|
+
font-weight: 600;
|
|
1243
|
+
}
|
|
1244
|
+
.md-output pre {
|
|
1245
|
+
margin-top: 8px;
|
|
1246
|
+
padding: 12px;
|
|
1247
|
+
background: var(--coar-background-neutral-secondary);
|
|
1248
|
+
border-radius: var(--coar-radius-xl);
|
|
1249
|
+
font-size: 12px;
|
|
1250
|
+
max-height: 200px;
|
|
1251
|
+
overflow: auto;
|
|
1252
|
+
white-space: pre-wrap;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
@media (max-width: 720px) {
|
|
1256
|
+
.md-color-demo {
|
|
1257
|
+
grid-template-columns: 1fr;
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
</style>
|
|
1261
|
+
```
|
|
1262
|
+
|
|
1263
|
+
The picker is rendered through the same overlay primitive (`menuPreset`) that powers menus, popovers, and sidebar flyouts: anchor-relative positioning, viewport flipping, scroll-reposition, plus outside-click and `Escape` dismissal — no bespoke layout or click-handling logic in the editor.
|
|
1264
|
+
|
|
1265
|
+
> **Info: Why a whitelist?**
|
|
1266
|
+
>
|
|
1267
|
+
> The viewer (`@cocoar/vue-markdown` and `@cocoar/vue-markdown-core`) and the editor share a single `sanitizeColor` helper that accepts only:
|
|
1268
|
+
>
|
|
1269
|
+
> - Hex (`#rgb`, `#rrggbb`, with optional alpha)
|
|
1270
|
+
> - `rgb()` / `rgba()` and the modern space-separated form
|
|
1271
|
+
> - `hsl()` / `hsla()` and the modern space-separated form
|
|
1272
|
+
> - A small set of named CSS colors (`red`, `blue`, …, `transparent`, `currentcolor`)
|
|
1273
|
+
>
|
|
1274
|
+
> Anything else — `var(--token)`, `url(...)`, `expression(...)`, multi-declaration styles, foreign attributes — is rejected. A failed sanitization falls through to plain text in the viewer and keeps the surrounding content intact in the editor. There's no way for a hostile markdown payload to leak inline style beyond a single `color` declaration.
|
|
1275
|
+
>
|
|
1276
|
+
> The picker palette (`COAR_TEXT_COLOR_PALETTE`) is exported so consumers can mirror it in custom UI.
|
|
1277
|
+
|
|
1278
|
+
## Custom Embeds
|
|
1279
|
+
|
|
1280
|
+
Register your own Vue components against a `:::key{props}` directive and the
|
|
1281
|
+
editor folds them into live, editable blocks (the viewer renders the same
|
|
1282
|
+
component read-only). Pass an `embeds` registry and use the `cocoar` flavor:
|
|
1283
|
+
|
|
1284
|
+
```vue
|
|
1285
|
+
<CoarMarkdownEditor
|
|
1286
|
+
v-model="value"
|
|
1287
|
+
flavor="cocoar"
|
|
1288
|
+
:embeds="embeds"
|
|
1289
|
+
:tools="tools"
|
|
1290
|
+
toolbar-mode="both"
|
|
1291
|
+
/>
|
|
1292
|
+
```
|
|
1293
|
+
|
|
1294
|
+
This is its own topic — see the dedicated **[Custom Embeds](./markdown-embeds.md)**
|
|
1295
|
+
page for the registry shape, the editor `controller` contract, toolbar insert
|
|
1296
|
+
placement, and a live demo.
|
|
1297
|
+
|
|
1298
|
+
## Editor ↔ Viewer Parity
|
|
1299
|
+
|
|
1300
|
+
`<CoarMarkdownEditor>` and `<CoarMarkdown>` (the viewer) read the **same shared stylesheet** (`@cocoar/vue-markdown/styles`) so a markdown document looks pixel-identical whether you're editing it or rendering it for display. The two render through different DOM shapes — the editor's PM-managed contenteditable emits bare `<li>` / `<td>` / `<blockquote>` nodes inside a `.ProseMirror` wrapper, while the viewer emits class-tagged elements (`.coar-markdown-list-item`, etc.) — and the shared stylesheet covers both via parallel `:where(…)` selectors:
|
|
1301
|
+
|
|
1302
|
+
| Concern | Note |
|
|
1303
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1304
|
+
| Vertical rhythm | Block margins apply to direct children of `.coar-markdown` (viewer) **and** `.coar-markdown .ProseMirror` (editor). |
|
|
1305
|
+
| Typography | Heading sizes, blockquote inset, list indentation, `<strong>` weight (700), inline-code color, link underline — all defined once. |
|
|
1306
|
+
| Tables | Zebra alternation uses `:nth-child(<n> of :not([data-is-header]))` to handle Milkdown's `<tr data-is-header>`-inside-`<tbody>` shape and the viewer's classic `<thead>` / `<tbody>` split with one rule. |
|
|
1307
|
+
| Task lists | `<li data-item-type="task" data-checked="true">` in both panes; the visual checkbox is a `::before` pseudo-element (no native `<input>`). Completed items get the muted-color strikethrough. |
|
|
1308
|
+
| Cell padding | `<p>` user-agent margin reset to `0` inside `<li>` / `<td>` / `<th>` — without the reset PM's auto-wrapped paragraph would add ~1em of vertical whitespace per row. |
|
|
1309
|
+
|
|
1310
|
+
If you embed the editor next to a viewer pane (the playground's "viewer pane" toggle does exactly this), the two should render the same source identically. Differences narrow down to design tokens you can override globally:
|
|
1311
|
+
|
|
1312
|
+
| Variable | Default | Effect |
|
|
1313
|
+
| ------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
1314
|
+
| `--coar-markdown-heading-block-start` | `var(--coar-spacing-xl, 2rem)` | Extra space above every top-level heading. Lower for tighter docs, raise for more whitespace. |
|
|
1315
|
+
| `--coar-markdown-space-2` | `var(--coar-spacing-m, 1rem)` | Default block-end margin. Drives paragraph / list / table / blockquote spacing. |
|
|
1316
|
+
| `--coar-markdown-link` | `var(--coar-text-brand-primary)` | Link color (also applied to inline code). |
|
|
1317
|
+
| `--coar-markdown-border` | `var(--coar-border-neutral-tertiary)` | Used by tables, blockquote, `<hr>`. |
|
|
1318
|
+
|
|
1319
|
+
## Toolbar layout (`tools`)
|
|
1320
|
+
|
|
1321
|
+
Pass a `tools` array to control which buttons the **sidebar** toolbar exposes and
|
|
1322
|
+
in what order. When omitted, the default layout is used. **The array order IS the
|
|
1323
|
+
toolbar order** — entries render top-to-bottom (or left-to-right) as listed.
|
|
1324
|
+
|
|
1325
|
+
```vue
|
|
1326
|
+
<!-- Minimal subset, in the given order -->
|
|
1327
|
+
<CoarMarkdownEditor
|
|
1328
|
+
v-model="value"
|
|
1329
|
+
:tools="['bold', 'italic', 'bulletList', 'orderedList', 'outdent', 'indent', 'clearFormatting']"
|
|
1330
|
+
/>
|
|
1331
|
+
|
|
1332
|
+
<!-- All tools except tables -->
|
|
1333
|
+
<script setup lang="ts">
|
|
1334
|
+
import { COAR_MARKDOWN_EDITOR_ALL_TOOLS } from '@cocoar/vue-markdown-editor';
|
|
1335
|
+
const tools = COAR_MARKDOWN_EDITOR_ALL_TOOLS.filter((t) => t !== 'table' && t !== 'tableOps');
|
|
1336
|
+
</script>
|
|
1337
|
+
<CoarMarkdownEditor v-model="value" :tools="tools" />
|
|
1338
|
+
```
|
|
1339
|
+
|
|
1340
|
+
> **Warning: Order is now significant**
|
|
1341
|
+
>
|
|
1342
|
+
> Earlier, a flat `tools` array was an order-insensitive whitelist filtering a fixed
|
|
1343
|
+
> sequence. It is now an **ordered layout**. If you relied on canonical ordering,
|
|
1344
|
+
> list the tools in the order you want; the default (omit `tools`) is unchanged.
|
|
1345
|
+
|
|
1346
|
+
### Groups and custom items
|
|
1347
|
+
|
|
1348
|
+
An entry can also be a **flyout group** (a submenu) or a `'divider'`. A group
|
|
1349
|
+
bundles any mix of built-in tools and [custom-embed](./markdown-embeds.md)
|
|
1350
|
+
inserts (`embed:<key>`) behind one button:
|
|
1351
|
+
|
|
1352
|
+
```ts
|
|
1353
|
+
import type { CoarMarkdownEditorToolEntry } from '@cocoar/vue-markdown-editor';
|
|
1354
|
+
|
|
1355
|
+
const tools: CoarMarkdownEditorToolEntry[] = [
|
|
1356
|
+
'bold',
|
|
1357
|
+
'italic',
|
|
1358
|
+
'headings',
|
|
1359
|
+
'divider',
|
|
1360
|
+
{ flyout: ['table', 'image', 'embed:chart'], label: 'Insert', icon: 'plus' },
|
|
1361
|
+
'divider',
|
|
1362
|
+
'undo',
|
|
1363
|
+
'redo',
|
|
1364
|
+
];
|
|
1365
|
+
```
|
|
1366
|
+
|
|
1367
|
+
| Entry shape | Meaning |
|
|
1368
|
+
| ----------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
1369
|
+
| `'bold'` (a `CoarMarkdownEditorTool`) | A built-in tool. |
|
|
1370
|
+
| `` `embed:chart` `` (a `` `embed:${string}` ``) | A registered custom embed's insert item — see [Custom Embeds](./markdown-embeds.md). |
|
|
1371
|
+
| `{ flyout: ToolRef[], label?, icon? }` | A flyout submenu containing any of the above. |
|
|
1372
|
+
| `'divider'` | A separator. |
|
|
1373
|
+
|
|
1374
|
+
### Tool identifiers
|
|
1375
|
+
|
|
1376
|
+
| Tool | Description |
|
|
1377
|
+
| -------------------------------------------- | ----------------------------------------------------------------------- |
|
|
1378
|
+
| `bold` `italic` `strikethrough` `inlineCode` | Inline marks |
|
|
1379
|
+
| `textColor` | Text color picker — see [Text Color](#text-color) |
|
|
1380
|
+
| `headings` | Heading flyout (H1–H6 + paragraph) |
|
|
1381
|
+
| `bulletList` `orderedList` `taskList` | List variants |
|
|
1382
|
+
| `indent` `outdent` | List nesting controls |
|
|
1383
|
+
| `blockquote` `horizontalRule` | Block elements |
|
|
1384
|
+
| `codeBlock` `table` `image` | Insert blocks (sidebar only) |
|
|
1385
|
+
| `tableOps` | Insert/Delete row/col, shown contextually when cursor is inside a table |
|
|
1386
|
+
| `clearFormatting` | Strip all marks + reset block to paragraph |
|
|
1387
|
+
| `undo` `redo` | History |
|
|
1388
|
+
|
|
1389
|
+
> **Info: Markdown-only formatting**
|
|
1390
|
+
>
|
|
1391
|
+
> Only formatting that round-trips through Markdown is exposed. There is intentionally **no underline, font-family, font-size, or alignment** — these have no Markdown representation and would silently break round-trip persistence. **Text color** is the one exception: it round-trips as plain inline HTML through a strict whitelist sanitizer (see [Text Color](#text-color)).
|
|
1392
|
+
>
|
|
1393
|
+
> When migrating from a richtext editor that exposed those tools, the closest Markdown-native substitutes are:
|
|
1394
|
+
>
|
|
1395
|
+
> | Richtext tool | Markdown equivalent |
|
|
1396
|
+
> | ---------------------------------------- | ---------------------------------------------------- |
|
|
1397
|
+
> | Font size | `headings` — H1–H6 provide the typographic hierarchy |
|
|
1398
|
+
> | Bold / italic | `bold` / `italic` (no change) |
|
|
1399
|
+
> | Bulleted / numbered list | `bulletList` / `orderedList` |
|
|
1400
|
+
> | Indent / outdent (in lists) | `indent` / `outdent` |
|
|
1401
|
+
> | Clear / eraser | `clearFormatting` |
|
|
1402
|
+
> | Underline, color, alignment, font-family | _no equivalent — drop or accept embedded HTML_ |
|
|
1403
|
+
>
|
|
1404
|
+
|
|
1405
|
+
## Props
|
|
1406
|
+
|
|
1407
|
+
| Prop | Type | Default | Description |
|
|
1408
|
+
| ----------------- | -------------------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1409
|
+
| `modelValue` | `string` | `''` | Markdown content (use with `v-model`) |
|
|
1410
|
+
| `readonly` | `boolean` | `false` | Disable editing (keeps the layout, suppresses the toolbar) |
|
|
1411
|
+
| `disabled` | `boolean` | `false` | Disabled state — non-interactive, dimmed. Auto-picked up from `CoarFormField` |
|
|
1412
|
+
| `error` | `boolean` | `false` | Error state — adds outline + `aria-invalid`. Auto-picked up from `CoarFormField.error` |
|
|
1413
|
+
| `id` | `string` | _(auto)_ | HTML id. Auto-generated if omitted; `CoarFormField`'s id takes precedence |
|
|
1414
|
+
| `name` | `string` | _undefined_ | Reflected as `data-name` for form-submission tooling |
|
|
1415
|
+
| `required` | `boolean` | `false` | Sets `aria-required="true"` |
|
|
1416
|
+
| `placeholder` | `string` | `''` | Markdown hint shown while the editor is empty. Overlay-only — never written to `modelValue`. See [Placeholder](#placeholder) |
|
|
1417
|
+
| `sourceToggle` | `boolean` | `false` | Show a Rendered ↔ Source toggle for editing the raw Markdown. See [Source view](#source-view-raw-markdown) |
|
|
1418
|
+
| `toolbarMode` | `'floating' \| 'fixed' \| 'both' \| 'external'` | `'floating'` | Toolbar layout; `external` connects the editor to a shared `CoarMarkdownToolbar` |
|
|
1419
|
+
| `toolbarPosition` | `'left' \| 'right' \| 'top' \| 'bottom'` | `'left'` | Toolbar edge when `toolbarMode` is `'fixed'` or `'both'`. `top`/`bottom` render a horizontal toolbar; flyouts open along the perpendicular axis. |
|
|
1420
|
+
| `tools` | `CoarMarkdownEditorToolEntry[]` | _default layout_ | Ordered toolbar layout (built-in ids, `embed:<key>` refs, `{ flyout }` groups, `'divider'`). See [Toolbar layout](#toolbar-layout-tools) |
|
|
1421
|
+
| `flavor` | `'commonmark' \| 'gfm' \| 'cocoar' \| { gfm?, textColor?, embeds? }` | `'cocoar'` | Portability contract — hard-enforces which features can be authored. See [Flavors](#flavors-portability) |
|
|
1422
|
+
| `embeds` | `EmbedRegistry` | _undefined_ | Custom-embed registry (`:::key{props}` → your component). Requires the `embeds` capability (`cocoar` flavor). See [Custom Embeds](./markdown-embeds.md) |
|
|
1423
|
+
| `uploadImage` | `(file: File) => Promise<{ url: string; alt?: string }>` | _undefined_ | Enables paste / drag-drop image upload. Returns the stored image's URL. See [Images](#images) |
|
|
1424
|
+
| `pickImage` | `(ctx: ImagePickContext) => void` | _undefined_ | Override the Insert Image button with your own asset picker. See [Custom image source](#custom-image-source-pickimage) |
|
|
1425
|
+
|
|
1426
|
+
## Events
|
|
1427
|
+
|
|
1428
|
+
| Event | Payload | Description |
|
|
1429
|
+
| ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
1430
|
+
| `update:modelValue` | `string` | Fired on every internal markdown change. The editor de-duplicates — if a parent echoes the value back unchanged, no second update fires. |
|
|
1431
|
+
|
|
1432
|
+
## Floating-Toolbar Contexts
|
|
1433
|
+
|
|
1434
|
+
The floating toolbar swaps its contents based on what's selected:
|
|
1435
|
+
|
|
1436
|
+
| Selection | Toolbar |
|
|
1437
|
+
| ------------------------ | ------------------------------------------------------------------------------------ |
|
|
1438
|
+
| Text outside a table | Bold, Italic, Strikethrough, Inline Code, Headings flyout, Blockquote |
|
|
1439
|
+
| Text inside a table cell | Row insert above/below, Column insert left/right, Delete cell, plus Bold/Italic/Code |
|
|
1440
|
+
|
|
1441
|
+
Detection runs on the ProseMirror selection state via `editorViewCtx`. CellSelections are ProseMirror-internal and don't fire `selectionchange` — the column- and row-handle toolbars referenced in the architecture below are not wired up yet (see [TODO](#todo)).
|
|
1442
|
+
|
|
1443
|
+
## Architecture Notes
|
|
1444
|
+
|
|
1445
|
+
### Why Milkdown (not TipTap, not Crepe)
|
|
1446
|
+
|
|
1447
|
+
| | Milkdown Kit | TipTap | Milkdown Crepe |
|
|
1448
|
+
| ------------ | --------------------------------------------------------------------------------- | ---------------------------------- | ----------------------------------- |
|
|
1449
|
+
| Data format | **Markdown-first** (lossless round-trip) | JSON-first (lossy markdown export) | Markdown-first |
|
|
1450
|
+
| Shared stack | Same as `@cocoar/vue-markdown-core`: unified@^11, remark-parse@^11, remark-gfm@^4 | No overlap | Same |
|
|
1451
|
+
| Bundle | ~137 KB gzip (Kit) | Similar | ~2 MB (CodeMirror, KaTeX, etc.) |
|
|
1452
|
+
| UI control | Full — headless, own components | Full — headless | Limited — predefined Notion-like UI |
|
|
1453
|
+
| License | MIT | MIT (core), paid (collab) | MIT |
|
|
1454
|
+
|
|
1455
|
+
The Kit approach gives full control over UI while sharing the remark pipeline with the existing `@cocoar/vue-markdown-core` parser and `<CoarMarkdown>` viewer.
|
|
1456
|
+
|
|
1457
|
+
### Why no `tableBlock` plugin
|
|
1458
|
+
|
|
1459
|
+
`@milkdown/components/table-block` provides edge-handle buttons, button-group popups, and drag-to-reorder, but:
|
|
1460
|
+
|
|
1461
|
+
- Clicking in a cell auto-selects all content (breaks normal editing)
|
|
1462
|
+
- Button-group popup overlaps with the floating toolbar
|
|
1463
|
+
- CellSelection is internal to ProseMirror — `window.getSelection()` returns `type: "None"`, so `selectionchange` doesn't fire and detection is unreliable
|
|
1464
|
+
- Requires ~200 lines of CSS to style properly
|
|
1465
|
+
|
|
1466
|
+
Table operations are instead exposed via the floating toolbar (when the cursor is inside a cell) and the sidebar toolbar (always available in fixed mode). Custom edge-handles will be built when a stable design is settled.
|
|
1467
|
+
|
|
1468
|
+
## TODO
|
|
1469
|
+
|
|
1470
|
+
- [ ] Link insert/edit dialog
|
|
1471
|
+
- [x] Image support (insert by URL, paste / drag-drop upload, custom `pickImage`)
|
|
1472
|
+
- [x] Table create (size picker + `|CxR|`), column alignment, delete table
|
|
1473
|
+
- [x] Hover edge-handles (row/column grips on all four edges → insert / delete menu)
|
|
1474
|
+
- [ ] Task list checkbox rendering and toggling
|
|
1475
|
+
- [ ] Use `computeOverlayCoordinates` for floating toolbar positioning instead of viewport clamping
|
|
1476
|
+
- [ ] Slash commands for block insertions
|
|
1477
|
+
- [ ] Block drag handle
|
|
1478
|
+
- [ ] Code block syntax highlighting (Prism or Shiki)
|