@kungal/editor-nuxt 0.10.0 → 0.12.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # @kungal/editor-nuxt
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5786594: Add a `#toolbar` slot and a KunUI toolbar — the toolbar is now a swappable layer.
8
+
9
+ `<KunEditor>` exposes a `#toolbar` scoped slot whose props are the command API
10
+ (`KunEditorToolbarApi`: `run(cmdKey, payload)` / `insertText` / `insertQuote` /
11
+ `insertMention` / `focus` / `adapters` / `features` / `locale`). The API is
12
+ computed inside the Milkdown providers and handed down, so a custom toolbar can
13
+ live in the consumer's tree without `useInstance`/`inject`. The hand-rolled
14
+ default toolbar is the slot's fallback — omit the slot and nothing changes.
15
+
16
+ `@kungal/editor-nuxt` ships `<KunEditorToolbar>` (auto-imported): the same
17
+ toolbar built with **KunButton / KunIcon / KunTooltip / KunPopover** (real tooltip
18
+ delay, popover focus/collision, a11y):
19
+
20
+ ```vue
21
+ <KunEditor v-model="md" :adapters="adapters">
22
+ <template #toolbar="api"><KunEditorToolbar v-bind="api" /></template>
23
+ </KunEditor>
24
+ ```
25
+
26
+ This keeps `@kungal/editor-vue` **headless / zero UI-kit dependency** (any Vue app
27
+ can use it with the default toolbar) while the KunUI ecosystem (forum, moyu) gets
28
+ native chrome — the "headless core + optional UI layer" pattern (TipTap UI,
29
+ BlockNote's swappable toolbar). Also exports `EMOJI` from `@kungal/editor-vue` for
30
+ building custom pickers.
31
+
32
+ ### Patch Changes
33
+
34
+ - Updated dependencies [5786594]
35
+ - @kungal/editor-vue@0.12.0
36
+ - @kungal/editor-core@0.12.0
37
+
38
+ ## 0.11.0
39
+
40
+ ### Patch Changes
41
+
42
+ - @kungal/editor-vue@0.11.0
43
+
3
44
  ## 0.10.0
4
45
 
5
46
  ### Patch Changes
package/nuxt.config.ts CHANGED
@@ -1,20 +1,29 @@
1
- import { defineNuxtModule, addComponent } from '@nuxt/kit'
1
+ import { defineNuxtModule, addComponent, createResolver } from '@nuxt/kit'
2
2
 
3
- // KunEditor Nuxt layer. The component lives (already compiled) in the
4
- // framework-agnostic-friendly @kungal/editor-vue package; this layer just
5
- // registers it as a Nuxt auto-import so downstream templates can use
6
- // `<KunEditor>` with no import — and Nuxt generates its types, so the tag stays
7
- // type-checked in consumer templates.
3
+ // KunEditor Nuxt layer. <KunEditor> itself lives in the framework-agnostic,
4
+ // headless @kungal/editor-vue; this layer registers it as a Nuxt auto-import so
5
+ // downstream templates use `<KunEditor>` with no import (and Nuxt generates its
6
+ // types, keeping the tag type-checked in consumer templates).
7
+ //
8
+ // It also ships <KunEditorToolbar>: the KunUI-built toolbar for <KunEditor>'s
9
+ // #toolbar slot. It belongs HERE (this layer already assumes @kungal/ui-nuxt),
10
+ // not in headless @kungal/editor-vue — so editor-vue stays UI-kit-free while the
11
+ // KunUI ecosystem gets native chrome. Same shape as TipTap's optional UI pkg.
8
12
  export default defineNuxtConfig({
9
13
  modules: [
10
14
  defineNuxtModule({
11
15
  meta: { name: 'kun-editor-components' },
12
16
  setup() {
17
+ const { resolve } = createResolver(import.meta.url)
13
18
  addComponent({
14
19
  name: 'KunEditor',
15
20
  export: 'KunEditor',
16
21
  filePath: '@kungal/editor-vue'
17
22
  })
23
+ addComponent({
24
+ name: 'KunEditorToolbar',
25
+ filePath: resolve('./runtime/KunEditorToolbar.vue')
26
+ })
18
27
  }
19
28
  })
20
29
  ]
@@ -24,5 +33,5 @@ export default defineNuxtConfig({
24
33
  // consuming app owns one stylesheet and adds `@kungal/editor-vue/style.css`
25
34
  // alongside its @kungal/ui-tokens + @kungal/ui-vue setup — the @source scan
26
35
  // path is node_modules-layout-specific, so only the app can write it. It also
27
- // assumes @kungal/ui-nuxt is already extended (KunEditor renders KunUI chrome).
28
- // See this package's README.
36
+ // assumes @kungal/ui-nuxt is already extended: <KunEditorToolbar> (and the
37
+ // reference editor styles) render KunUI chrome. See this package's README.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungal/editor-nuxt",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "KunEditor Nuxt Layer — wraps @kungal/editor-vue and auto-imports <KunEditor> so an existing Nuxt app keeps its exact DX (no import needed).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -26,13 +26,16 @@
26
26
  "files": [
27
27
  "CHANGELOG.md",
28
28
  "app",
29
+ "runtime",
29
30
  "nuxt.config.ts"
30
31
  ],
31
32
  "dependencies": {
32
- "@kungal/editor-vue": "0.10.0"
33
+ "@kungal/editor-core": "0.12.0",
34
+ "@kungal/editor-vue": "0.12.0"
33
35
  },
34
36
  "peerDependencies": {
35
37
  "@kungal/ui-vue": "^2.7.0",
38
+ "@milkdown/kit": "^7.21.2",
36
39
  "nuxt": "^4.0.0",
37
40
  "vue": "^3.5.0"
38
41
  },
@@ -0,0 +1,246 @@
1
+ <script setup lang="ts">
2
+ // A KunUI-built toolbar for <KunEditor>, dropped into its #toolbar scoped slot:
3
+ //
4
+ // <KunEditor v-model="md" :adapters="a">
5
+ // <template #toolbar="api"><KunEditorToolbar v-bind="api" /></template>
6
+ // </KunEditor>
7
+ //
8
+ // This lives in @kungal/editor-nuxt (the layer that already assumes @kungal/
9
+ // ui-nuxt), NOT in the headless @kungal/editor-vue — so editor-vue keeps its
10
+ // zero-UI-dep promise while the KunUI ecosystem (forum, moyu) gets native
11
+ // KunButton / KunIcon / KunTooltip / KunPopover chrome. It drives the editor
12
+ // purely through the slot API props (KunEditorToolbarApi) — no useInstance.
13
+ //
14
+ // KunButton/KunIcon/KunTooltip/KunPopover are auto-imported by @kungal/ui-nuxt in
15
+ // the consuming app; icons use the app's iconify set (e.g. lucide via @nuxt/icon).
16
+ import { computed, ref } from 'vue'
17
+ import {
18
+ createCodeBlockCommand,
19
+ insertHrCommand,
20
+ insertImageCommand,
21
+ toggleEmphasisCommand,
22
+ toggleInlineCodeCommand,
23
+ toggleStrongCommand,
24
+ wrapInBlockquoteCommand,
25
+ wrapInBulletListCommand,
26
+ wrapInHeadingCommand,
27
+ wrapInOrderedListCommand
28
+ } from '@milkdown/kit/preset/commonmark'
29
+ import { toggleStrikethroughCommand } from '@milkdown/kit/preset/gfm'
30
+ import {
31
+ insertKunSpoilerCommand,
32
+ toggleLatexCommand
33
+ } from '@kungal/editor-core/preset'
34
+ import { EMOJI } from '@kungal/editor-vue'
35
+ import type { KunEditorToolbarApi, StickerPack } from '@kungal/editor-vue'
36
+
37
+ const props = defineProps<KunEditorToolbarApi>()
38
+
39
+ const en = computed(() => props.locale.toLowerCase().startsWith('en'))
40
+ const t = computed(() => ({
41
+ bold: en.value ? 'Bold' : '加粗',
42
+ italic: en.value ? 'Italic' : '斜体',
43
+ strike: en.value ? 'Strikethrough' : '删除线',
44
+ code: en.value ? 'Inline code' : '行内代码',
45
+ h1: en.value ? 'Heading 1' : '一级标题',
46
+ h2: en.value ? 'Heading 2' : '二级标题',
47
+ h3: en.value ? 'Heading 3' : '三级标题',
48
+ bulletList: en.value ? 'Bullet list' : '无序列表',
49
+ orderedList: en.value ? 'Ordered list' : '有序列表',
50
+ quote: en.value ? 'Blockquote' : '引用块',
51
+ codeBlock: en.value ? 'Code block' : '代码块',
52
+ hr: en.value ? 'Divider' : '分隔线',
53
+ spoiler: en.value ? 'Spoiler' : '隐藏文本',
54
+ latex: en.value ? 'Math (LaTeX)' : '公式',
55
+ image: en.value ? 'Upload image' : '上传图片',
56
+ picker: en.value ? 'Emoji & stickers' : '表情与贴纸',
57
+ emoji: en.value ? 'Emoji' : '表情',
58
+ sticker: en.value ? 'Stickers' : '贴纸',
59
+ empty: en.value ? 'No stickers' : '暂无贴纸',
60
+ failed: en.value ? 'Failed to load stickers' : '贴纸加载失败'
61
+ }))
62
+
63
+ interface Tool {
64
+ icon: string
65
+ title: string
66
+ run: () => void
67
+ }
68
+
69
+ // Same command set + grouping as the default toolbar; `.key` is read at click
70
+ // time via api.run (populated once the editor is created).
71
+ const groups = computed<Tool[][]>(() => [
72
+ [
73
+ { icon: 'lucide:bold', title: t.value.bold, run: () => props.run(toggleStrongCommand.key) },
74
+ { icon: 'lucide:italic', title: t.value.italic, run: () => props.run(toggleEmphasisCommand.key) },
75
+ { icon: 'lucide:strikethrough', title: t.value.strike, run: () => props.run(toggleStrikethroughCommand.key) },
76
+ { icon: 'lucide:code', title: t.value.code, run: () => props.run(toggleInlineCodeCommand.key) }
77
+ ],
78
+ [
79
+ { icon: 'lucide:heading-1', title: t.value.h1, run: () => props.run(wrapInHeadingCommand.key, 1) },
80
+ { icon: 'lucide:heading-2', title: t.value.h2, run: () => props.run(wrapInHeadingCommand.key, 2) },
81
+ { icon: 'lucide:heading-3', title: t.value.h3, run: () => props.run(wrapInHeadingCommand.key, 3) }
82
+ ],
83
+ [
84
+ { icon: 'lucide:list', title: t.value.bulletList, run: () => props.run(wrapInBulletListCommand.key) },
85
+ { icon: 'lucide:list-ordered', title: t.value.orderedList, run: () => props.run(wrapInOrderedListCommand.key) },
86
+ { icon: 'lucide:text-quote', title: t.value.quote, run: () => props.run(wrapInBlockquoteCommand.key) },
87
+ { icon: 'lucide:square-code', title: t.value.codeBlock, run: () => props.run(createCodeBlockCommand.key, '') },
88
+ { icon: 'lucide:minus', title: t.value.hr, run: () => props.run(insertHrCommand.key) }
89
+ ],
90
+ [
91
+ { icon: 'lucide:eye-off', title: t.value.spoiler, run: () => props.run(insertKunSpoilerCommand.key) },
92
+ { icon: 'lucide:sigma', title: t.value.latex, run: () => props.run(toggleLatexCommand.key) }
93
+ ]
94
+ ])
95
+
96
+ // Image upload — only when the host supplies uploadImage. Same adapter as
97
+ // paste/drop; on success inserts an image node at the cursor.
98
+ const uploadImage = computed(() => props.adapters.uploadImage)
99
+ const fileInput = ref<HTMLInputElement | null>(null)
100
+ const pickImage = () => fileInput.value?.click()
101
+ const onFileChange = async (e: Event) => {
102
+ const input = e.target as HTMLInputElement
103
+ const file = input.files?.[0]
104
+ input.value = ''
105
+ const upload = uploadImage.value
106
+ if (!file || !upload) return
107
+ try {
108
+ const src = await upload(file)
109
+ props.run(insertImageCommand.key, { src, title: file.name, alt: file.name })
110
+ } catch {
111
+ props.adapters.notify?.(en.value ? 'Image upload failed' : '图片上传失败', 'error')
112
+ }
113
+ }
114
+
115
+ // Emoji (built-in) + sticker (adapter) picker in a KunPopover.
116
+ const showPicker = computed(() => props.features.sticker !== false)
117
+ const stickerSource = computed(() => props.adapters.stickerSource)
118
+ const hasSticker = computed(() => !!stickerSource.value)
119
+ const tab = ref<'emoji' | 'sticker'>('emoji')
120
+ const packs = ref<StickerPack[]>([])
121
+ const loaded = ref(false)
122
+ const loadFailed = ref(false)
123
+ const loadStickers = async () => {
124
+ const source = stickerSource.value
125
+ if (loaded.value || !source) return
126
+ loaded.value = true
127
+ try {
128
+ packs.value = await source()
129
+ } catch {
130
+ loadFailed.value = true
131
+ }
132
+ }
133
+ const insertSticker = (src: string, name: string) =>
134
+ props.run(insertImageCommand.key, { src, title: name, alt: name })
135
+ </script>
136
+
137
+ <template>
138
+ <div class="flex flex-wrap items-center gap-0.5">
139
+ <template v-for="(group, gi) in groups" :key="gi">
140
+ <span v-if="gi > 0" class="bg-default-200 mx-1 h-5 w-px" aria-hidden="true" />
141
+ <KunTooltip v-for="(b, bi) in group" :key="bi" :text="b.title" :delay-show="300">
142
+ <KunButton
143
+ variant="light"
144
+ size="sm"
145
+ :is-icon-only="true"
146
+ :aria-label="b.title"
147
+ @click="b.run()"
148
+ >
149
+ <KunIcon :name="b.icon" />
150
+ </KunButton>
151
+ </KunTooltip>
152
+ </template>
153
+
154
+ <template v-if="uploadImage">
155
+ <span class="bg-default-200 mx-1 h-5 w-px" aria-hidden="true" />
156
+ <KunTooltip :text="t.image" :delay-show="300">
157
+ <KunButton
158
+ variant="light"
159
+ size="sm"
160
+ :is-icon-only="true"
161
+ :aria-label="t.image"
162
+ @click="pickImage"
163
+ >
164
+ <KunIcon name="lucide:image" />
165
+ </KunButton>
166
+ </KunTooltip>
167
+ <input
168
+ ref="fileInput"
169
+ type="file"
170
+ accept=".jpg,.jpeg,.png,.webp,.gif"
171
+ hidden
172
+ @change="onFileChange"
173
+ />
174
+ </template>
175
+
176
+ <template v-if="showPicker">
177
+ <span class="bg-default-200 mx-1 h-5 w-px" aria-hidden="true" />
178
+ <KunPopover position="bottom-start" :opaque="true" inner-class="w-72 p-2">
179
+ <template #trigger>
180
+ <KunButton
181
+ variant="light"
182
+ size="sm"
183
+ :is-icon-only="true"
184
+ :aria-label="t.picker"
185
+ @click="hasSticker && loadStickers()"
186
+ >
187
+ <KunIcon name="lucide:smile-plus" />
188
+ </KunButton>
189
+ </template>
190
+
191
+ <div v-if="hasSticker" class="mb-2 flex gap-1">
192
+ <KunButton
193
+ :variant="tab === 'emoji' ? 'flat' : 'light'"
194
+ size="sm"
195
+ @click="tab = 'emoji'"
196
+ >{{ t.emoji }}</KunButton
197
+ >
198
+ <KunButton
199
+ :variant="tab === 'sticker' ? 'flat' : 'light'"
200
+ size="sm"
201
+ @click="tab = 'sticker'"
202
+ >{{ t.sticker }}</KunButton
203
+ >
204
+ </div>
205
+
206
+ <div
207
+ v-show="!hasSticker || tab === 'emoji'"
208
+ class="grid max-h-56 grid-cols-8 gap-0.5 overflow-y-auto"
209
+ >
210
+ <button
211
+ v-for="(e, i) in EMOJI"
212
+ :key="i"
213
+ type="button"
214
+ class="hover:bg-default-100 rounded p-1 text-lg leading-none"
215
+ @click="props.insertText(e)"
216
+ >
217
+ {{ e }}
218
+ </button>
219
+ </div>
220
+
221
+ <div v-if="hasSticker" v-show="tab === 'sticker'" class="max-h-56 overflow-y-auto">
222
+ <template v-if="packs.length">
223
+ <div v-for="pack in packs" :key="pack.name" class="mb-2">
224
+ <div class="text-default-500 mb-1 text-xs">{{ pack.name }}</div>
225
+ <div class="grid grid-cols-4 gap-1">
226
+ <button
227
+ v-for="(s, i) in pack.stickers"
228
+ :key="i"
229
+ type="button"
230
+ class="hover:bg-default-100 rounded p-1"
231
+ :title="s.name"
232
+ @click="insertSticker(s.src, s.name)"
233
+ >
234
+ <img :src="s.src" :alt="s.name" loading="lazy" class="h-14 w-14 object-contain" />
235
+ </button>
236
+ </div>
237
+ </div>
238
+ </template>
239
+ <div v-else class="text-default-500 p-4 text-center text-sm">
240
+ {{ loadFailed ? t.failed : t.empty }}
241
+ </div>
242
+ </div>
243
+ </KunPopover>
244
+ </template>
245
+ </div>
246
+ </template>