@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.
Files changed (179) hide show
  1. package/dist/components/checkbox/CoarCheckbox.vue.d.ts +1 -1
  2. package/dist/components/checkbox/CoarCheckbox.vue.d.ts.map +1 -1
  3. package/dist/components/code-block/CoarCodeBlock.vue.d.ts +1 -1
  4. package/dist/components/code-block/CoarCodeBlock.vue.d.ts.map +1 -1
  5. package/dist/components/data-list/CoarDataList.vue.d.ts +158 -0
  6. package/dist/components/data-list/CoarDataList.vue.d.ts.map +1 -0
  7. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts +30 -0
  8. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts.map +1 -0
  9. package/dist/components/data-list/data-list-builder.d.ts +288 -0
  10. package/dist/components/data-list/data-list-builder.d.ts.map +1 -0
  11. package/dist/components/data-list/index.d.ts +12 -0
  12. package/dist/components/data-list/index.d.ts.map +1 -0
  13. package/dist/components/data-list/internal/compare.d.ts +14 -0
  14. package/dist/components/data-list/internal/compare.d.ts.map +1 -0
  15. package/dist/components/data-list/internal/reorder-core.d.ts +46 -0
  16. package/dist/components/data-list/internal/reorder-core.d.ts.map +1 -0
  17. package/dist/components/data-list/internal/search.d.ts +16 -0
  18. package/dist/components/data-list/internal/search.d.ts.map +1 -0
  19. package/dist/components/data-list/internal/useDataListLines.d.ts +92 -0
  20. package/dist/components/data-list/internal/useDataListLines.d.ts.map +1 -0
  21. package/dist/components/data-list/internal/useDataListReorder.d.ts +68 -0
  22. package/dist/components/data-list/internal/useDataListReorder.d.ts.map +1 -0
  23. package/dist/components/data-list/internal/useSearchHighlight.d.ts +19 -0
  24. package/dist/components/data-list/internal/useSearchHighlight.d.ts.map +1 -0
  25. package/dist/components/data-list/types.d.ts +185 -0
  26. package/dist/components/data-list/types.d.ts.map +1 -0
  27. package/dist/components/data-list/useDataListModel.d.ts +79 -0
  28. package/dist/components/data-list/useDataListModel.d.ts.map +1 -0
  29. package/dist/components/date-time/_shared/maskito-config.d.ts +7 -2
  30. package/dist/components/date-time/_shared/maskito-config.d.ts.map +1 -1
  31. package/dist/components/date-time/_shared/time-helpers.d.ts +11 -4
  32. package/dist/components/date-time/_shared/time-helpers.d.ts.map +1 -1
  33. package/dist/components/date-time/_shared/use-date-picker-base.d.ts +1 -1
  34. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts +1 -1
  35. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts.map +1 -1
  36. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts +1 -1
  37. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts.map +1 -1
  38. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts +1 -1
  39. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts.map +1 -1
  40. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts +3 -0
  41. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts.map +1 -1
  42. package/dist/components/form-field/CoarFormField.vue.d.ts +1 -1
  43. package/dist/components/form-field/CoarFormField.vue.d.ts.map +1 -1
  44. package/dist/components/icon/CoarIcon.vue.d.ts +2 -2
  45. package/dist/components/icon/CoarIcon.vue.d.ts.map +1 -1
  46. package/dist/components/listbox/CoarListbox.vue.d.ts +7 -0
  47. package/dist/components/listbox/CoarListbox.vue.d.ts.map +1 -1
  48. package/dist/components/number-input/CoarNumberInput.vue.d.ts +1 -1
  49. package/dist/components/number-input/CoarNumberInput.vue.d.ts.map +1 -1
  50. package/dist/components/otp-input/CoarOtpInput.vue.d.ts +1 -1
  51. package/dist/components/otp-input/CoarOtpInput.vue.d.ts.map +1 -1
  52. package/dist/components/password-input/CoarPasswordInput.vue.d.ts +1 -1
  53. package/dist/components/password-input/CoarPasswordInput.vue.d.ts.map +1 -1
  54. package/dist/components/switch/CoarSwitch.vue.d.ts +1 -1
  55. package/dist/components/switch/CoarSwitch.vue.d.ts.map +1 -1
  56. package/dist/components/text-input/CoarTextInput.vue.d.ts +1 -1
  57. package/dist/components/text-input/CoarTextInput.vue.d.ts.map +1 -1
  58. package/dist/composables/dragRegistry.d.ts +12 -0
  59. package/dist/composables/dragRegistry.d.ts.map +1 -1
  60. package/dist/composables/index.d.ts +2 -2
  61. package/dist/composables/index.d.ts.map +1 -1
  62. package/dist/composables/useDragDrop.d.ts +62 -6
  63. package/dist/composables/useDragDrop.d.ts.map +1 -1
  64. package/dist/composables/useVirtualList.d.ts +21 -1
  65. package/dist/composables/useVirtualList.d.ts.map +1 -1
  66. package/dist/index.css +1 -1
  67. package/dist/index.d.ts +4 -2
  68. package/dist/index.d.ts.map +1 -1
  69. package/dist/index.js +4508 -2039
  70. package/package.json +5 -3
  71. package/skills/cocoar-vue-ui/SKILL.md +244 -0
  72. package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
  73. package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
  74. package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
  75. package/skills/cocoar-vue-ui/references/components/button.md +252 -0
  76. package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +353 -0
  77. package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1645 -0
  78. package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
  79. package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
  80. package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +475 -0
  81. package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
  82. package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
  83. package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
  84. package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
  85. package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
  86. package/skills/cocoar-vue-ui/references/components/card.md +151 -0
  87. package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
  88. package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
  89. package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
  90. package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
  91. package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
  92. package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
  93. package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
  94. package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
  95. package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
  96. package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
  97. package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
  98. package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
  99. package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
  100. package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
  101. package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
  102. package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
  103. package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
  104. package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
  105. package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
  106. package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
  107. package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
  108. package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
  109. package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
  110. package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
  111. package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
  112. package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
  113. package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
  114. package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
  115. package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
  116. package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
  117. package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
  118. package/skills/cocoar-vue-ui/references/components/link.md +235 -0
  119. package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
  120. package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
  121. package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
  122. package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
  123. package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
  124. package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
  125. package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
  126. package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
  127. package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
  128. package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
  129. package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
  130. package/skills/cocoar-vue-ui/references/components/note.md +96 -0
  131. package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
  132. package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
  133. package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
  134. package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
  135. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
  136. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
  137. package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
  138. package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
  139. package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
  140. package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
  141. package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
  142. package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
  143. package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
  144. package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
  145. package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
  146. package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
  147. package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
  148. package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
  149. package/skills/cocoar-vue-ui/references/components/select.md +463 -0
  150. package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
  151. package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
  152. package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
  153. package/skills/cocoar-vue-ui/references/components/table.md +170 -0
  154. package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
  155. package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
  156. package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
  157. package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
  158. package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
  159. package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
  160. package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
  161. package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
  162. package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
  163. package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
  164. package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
  165. package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
  166. package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
  167. package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
  168. package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
  169. package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
  170. package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
  171. package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
  172. package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
  173. package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
  174. package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
  175. package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
  176. package/skills/cocoar-vue-ui/references/guide/getting-started.md +119 -0
  177. package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
  178. package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
  179. 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 — `![alt](url "title")` — 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 &amp; 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 \`![alt](url)\`:
674
+
675
+ ![A scenic placeholder](https://picsum.photos/seed/cocoar/640/360 "Hover title")
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 `![alt](url "title")`.
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)