create-nala 3.1.0 → 3.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nala",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Scaffolding CLI tool for Nala Admin Dashboard Template",
5
5
  "author": "candrasp",
6
6
  "license": "MIT",
@@ -64,7 +64,7 @@ src/
64
64
  | **Framework** | Vue 3 (Composition API) | Always use `<script setup lang="ts">` |
65
65
  | **Build Tool** | Vite 8 (`@vitejs/plugin-vue`) | Super-fast HMR & production bundler |
66
66
  | **Styling** | Tailwind CSS v4 (`@tailwindcss/vite`) + `tw-animate-css` | OKLCH color tokens in `src/style.css` |
67
- | **UI Primitives** | `reka-ui` + shadcn-vue (New York style) | 45 primitive components in `src/components/ui/` |
67
+ | **UI Primitives** | `reka-ui` + shadcn-vue (New York style) | 46 primitive components in `src/components/ui/` (including `RichTextEditor`) |
68
68
  | **Charts** | `@unovis/ts` + `@unovis/vue` | SVG chart primitives: `AreaChart`, `BarChart`, `LineChart`, `DonutChart` |
69
69
  | **Data Table** | `@tanstack/vue-table` | Headless table logic with sorting & pagination |
70
70
  | **Form & Validation** | `vee-validate` + `@vee-validate/zod` + `zod` | Strongly-typed validation schemas |
@@ -226,6 +226,47 @@ fmt.bytes(1024 * 1024) // "1 MB"
226
226
  fmt.initials('John Doe') // "JD"
227
227
  ```
228
228
 
229
+ ### 7. Rich Text WYSIWYG Editor (`<RichTextEditor>`)
230
+ The template includes a full-featured Tiptap WYSIWYG editor primitive (`src/components/ui/editor/`). It is auto-imported globally as `<RichTextEditor>`.
231
+
232
+ ```vue
233
+ <script setup lang="ts">
234
+ import type { EditorChangePayload } from '@/components/ui/editor'
235
+
236
+ const content = ref('<p>Initial content...</p>')
237
+
238
+ function onEditorChange(payload: EditorChangePayload) {
239
+ // payload: { html, text, characterCount, wordCount }
240
+ }
241
+ </script>
242
+
243
+ <template>
244
+ <!-- Standard v-model usage -->
245
+ <RichTextEditor
246
+ v-model="content"
247
+ placeholder="Write your article or product description..."
248
+ :max-length="5000"
249
+ min-height="180px"
250
+ @change="onEditorChange"
251
+ />
252
+
253
+ <!-- Integration with VeeValidate <FormField> -->
254
+ <FormField v-slot="{ value, handleChange }" name="description">
255
+ <FormItem>
256
+ <FormLabel>Description</FormLabel>
257
+ <FormControl>
258
+ <RichTextEditor
259
+ :model-value="value"
260
+ @update:model-value="handleChange"
261
+ placeholder="Product details..."
262
+ />
263
+ </FormControl>
264
+ <FormMessage />
265
+ </FormItem>
266
+ </FormField>
267
+ </template>
268
+ ```
269
+
229
270
  ---
230
271
 
231
272
  ## 🚀 Golden Blueprint: End-to-End CRUD Feature Pattern
@@ -57,7 +57,7 @@ src/
57
57
  ├── assets/fonts/ # High-performance local Inter fonts (woff2)
58
58
  ├── components/
59
59
  │ ├── layout/ # Admin shell (AppNavbar, AppSidebar, CommandPalette)
60
- │ ├── ui/ # 45 accessible UI primitives (Reka UI / shadcn-vue)
60
+ │ ├── ui/ # 46 accessible UI primitives (Reka UI / shadcn-vue & Tiptap)
61
61
  │ ├── AppLogo.vue # Auto-imported brand logo component
62
62
  │ ├── EmptyState.vue # Auto-imported empty state component
63
63
  │ └── PageHeader.vue # Standardized page header component
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "NALA_APP_SLUG",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "Production-ready admin dashboard built with Vue 3, Vite, TypeScript, and Tailwind CSS v4.",
5
5
  "private": true,
6
6
  "type": "module",
@@ -14,6 +14,13 @@
14
14
  "dependencies": {
15
15
  "@lucide/vue": "^1.29.0",
16
16
  "@tailwindcss/vite": "^4.3.3",
17
+ "@tiptap/extension-image": "^3.31.2",
18
+ "@tiptap/extension-link": "^3.31.2",
19
+ "@tiptap/extension-placeholder": "^3.31.2",
20
+ "@tiptap/extension-underline": "^3.31.2",
21
+ "@tiptap/pm": "^3.31.2",
22
+ "@tiptap/starter-kit": "^3.31.2",
23
+ "@tiptap/vue-3": "^3.31.2",
17
24
  "@vee-validate/zod": "^4.15.1",
18
25
  "@vueuse/core": "^14.4.0",
19
26
  "axios": "^1.19.0",
@@ -0,0 +1,676 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
3
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
4
+ import StarterKit from '@tiptap/starter-kit'
5
+ import Underline from '@tiptap/extension-underline'
6
+ import Link from '@tiptap/extension-link'
7
+ import Image from '@tiptap/extension-image'
8
+ import Placeholder from '@tiptap/extension-placeholder'
9
+
10
+ import {
11
+ Bold,
12
+ Italic,
13
+ Underline as UnderlineIcon,
14
+ Strikethrough,
15
+ Code,
16
+ Heading1,
17
+ Heading2,
18
+ Heading3,
19
+ Pilcrow,
20
+ List,
21
+ ListOrdered,
22
+ Quote,
23
+ Code2,
24
+ Minus,
25
+ Link as LinkIcon,
26
+ Unlink,
27
+ Image as ImageIcon,
28
+ RotateCcw,
29
+ RotateCw,
30
+ RemoveFormatting,
31
+ Check,
32
+ } from '@lucide/vue'
33
+
34
+ import { Button } from '@/components/ui/button'
35
+ import { Toggle } from '@/components/ui/toggle'
36
+ import { Separator } from '@/components/ui/separator'
37
+ import {
38
+ Popover,
39
+ PopoverContent,
40
+ PopoverTrigger,
41
+ } from '@/components/ui/popover'
42
+ import {
43
+ DropdownMenu,
44
+ DropdownMenuContent,
45
+ DropdownMenuItem,
46
+ DropdownMenuTrigger,
47
+ } from '@/components/ui/dropdown-menu'
48
+ import { Input } from '@/components/ui/input'
49
+ import { Label } from '@/components/ui/label'
50
+ import { cn } from '@/lib/utils'
51
+
52
+ export interface EditorChangePayload {
53
+ html: string
54
+ text: string
55
+ characterCount: number
56
+ wordCount: number
57
+ }
58
+
59
+ interface Props {
60
+ modelValue?: string
61
+ placeholder?: string
62
+ disabled?: boolean
63
+ readonly?: boolean
64
+ autofocus?: boolean
65
+ hideToolbar?: boolean
66
+ hideBubbleMenu?: boolean
67
+ hideFooter?: boolean
68
+ maxLength?: number
69
+ minHeight?: string
70
+ maxHeight?: string
71
+ contentClass?: string
72
+ class?: string
73
+ }
74
+
75
+ const props = withDefaults(defineProps<Props>(), {
76
+ modelValue: '',
77
+ placeholder: 'Write something insightful...',
78
+ disabled: false,
79
+ readonly: false,
80
+ autofocus: false,
81
+ hideToolbar: false,
82
+ hideBubbleMenu: false,
83
+ hideFooter: false,
84
+ maxLength: undefined,
85
+ minHeight: '160px',
86
+ maxHeight: '480px',
87
+ contentClass: '',
88
+ class: '',
89
+ })
90
+
91
+ const emit = defineEmits<{
92
+ (e: 'update:modelValue', value: string): void
93
+ (e: 'change', payload: EditorChangePayload): void
94
+ (e: 'focus', event: FocusEvent): void
95
+ (e: 'blur', event: FocusEvent): void
96
+ }>()
97
+
98
+ // Popover states
99
+ const linkPopoverOpen = ref(false)
100
+ const linkUrl = ref('')
101
+ const linkOpenInNewTab = ref(true)
102
+
103
+ const imagePopoverOpen = ref(false)
104
+ const imageUrl = ref('')
105
+ const imageAlt = ref('')
106
+
107
+ const isFocused = ref(false)
108
+
109
+ const editor = useEditor({
110
+ content: props.modelValue,
111
+ editable: !props.disabled && !props.readonly,
112
+ autofocus: props.autofocus,
113
+ extensions: [
114
+ StarterKit.configure({
115
+ heading: {
116
+ levels: [1, 2, 3],
117
+ },
118
+ codeBlock: {
119
+ HTMLAttributes: {
120
+ class: 'rounded-lg bg-muted/90 p-4 font-mono text-xs my-3 border border-border/70 overflow-x-auto text-foreground',
121
+ },
122
+ },
123
+ blockquote: {
124
+ HTMLAttributes: {
125
+ class: 'border-l-2 border-primary pl-4 italic text-muted-foreground my-3',
126
+ },
127
+ },
128
+ horizontalRule: {
129
+ HTMLAttributes: {
130
+ class: 'my-4 border-t border-border',
131
+ },
132
+ },
133
+ bulletList: {
134
+ HTMLAttributes: {
135
+ class: 'list-disc pl-5 my-2 space-y-1',
136
+ },
137
+ },
138
+ orderedList: {
139
+ HTMLAttributes: {
140
+ class: 'list-decimal pl-5 my-2 space-y-1',
141
+ },
142
+ },
143
+ }),
144
+ Underline,
145
+ Link.configure({
146
+ openOnClick: false,
147
+ HTMLAttributes: {
148
+ class: 'text-primary underline font-medium hover:text-primary/80 transition-colors cursor-pointer',
149
+ },
150
+ }),
151
+ Image.configure({
152
+ HTMLAttributes: {
153
+ class: 'rounded-xl max-w-full h-auto border border-border/60 my-3 shadow-xs',
154
+ },
155
+ }),
156
+ Placeholder.configure({
157
+ placeholder: () => props.placeholder,
158
+ emptyEditorClass: 'is-editor-empty before:content-[attr(data-placeholder)] before:text-muted-foreground/50 before:float-left before:pointer-events-none before:h-0',
159
+ }),
160
+ ],
161
+ editorProps: {
162
+ attributes: {
163
+ class: cn(
164
+ 'focus:outline-hidden prose prose-sm dark:prose-invert max-w-none px-4 py-3 leading-relaxed text-foreground',
165
+ props.contentClass,
166
+ ),
167
+ style: `min-height: ${props.minHeight}; max-height: ${props.maxHeight}; overflow-y: auto;`,
168
+ },
169
+ },
170
+ onUpdate: ({ editor: instance }) => {
171
+ const html = instance.getHTML()
172
+ const text = instance.getText()
173
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0
174
+ const chars = text.length
175
+
176
+ emit('update:modelValue', html)
177
+ emit('change', {
178
+ html,
179
+ text,
180
+ characterCount: chars,
181
+ wordCount: words,
182
+ })
183
+ },
184
+ onFocus: ({ event }) => {
185
+ isFocused.value = true
186
+ emit('focus', event)
187
+ },
188
+ onBlur: ({ event }) => {
189
+ isFocused.value = false
190
+ emit('blur', event)
191
+ },
192
+ })
193
+
194
+ // Sync external modelValue changes
195
+ watch(
196
+ () => props.modelValue,
197
+ (newVal) => {
198
+ if (!editor.value) return
199
+ const isSame = editor.value.getHTML() === newVal
200
+ if (!isSame) {
201
+ editor.value.commands.setContent(newVal, { emitUpdate: false })
202
+ }
203
+ },
204
+ )
205
+
206
+ // Sync editable state
207
+ watch(
208
+ () => [props.disabled, props.readonly],
209
+ ([disabled, readonly]) => {
210
+ if (!editor.value) return
211
+ editor.value.setEditable(!disabled && !readonly)
212
+ },
213
+ )
214
+
215
+ // Computed stats
216
+ const textContent = computed(() => editor.value?.getText() ?? '')
217
+ const charCount = computed(() => textContent.value.length)
218
+ const wordCount = computed(() => {
219
+ const t = textContent.value.trim()
220
+ return t ? t.split(/\s+/).length : 0
221
+ })
222
+
223
+ const isOverLimit = computed(() => {
224
+ if (props.maxLength === undefined) return false
225
+ return charCount.value > props.maxLength
226
+ })
227
+
228
+ // Active Heading label
229
+ const currentHeadingLabel = computed(() => {
230
+ if (!editor.value) return 'Paragraph'
231
+ if (editor.value.isActive('heading', { level: 1 })) return 'Heading 1'
232
+ if (editor.value.isActive('heading', { level: 2 })) return 'Heading 2'
233
+ if (editor.value.isActive('heading', { level: 3 })) return 'Heading 3'
234
+ return 'Paragraph'
235
+ })
236
+
237
+ // Link popover handlers
238
+ function openLinkPopover() {
239
+ if (!editor.value) return
240
+ const previousUrl = editor.value.getAttributes('link').href || ''
241
+ linkUrl.value = previousUrl
242
+ linkOpenInNewTab.value = true
243
+ linkPopoverOpen.value = true
244
+ }
245
+
246
+ function applyLink() {
247
+ if (!editor.value) return
248
+ if (!linkUrl.value.trim()) {
249
+ editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
250
+ } else {
251
+ editor.value
252
+ .chain()
253
+ .focus()
254
+ .extendMarkRange('link')
255
+ .setLink({
256
+ href: linkUrl.value.trim(),
257
+ target: linkOpenInNewTab.value ? '_blank' : null,
258
+ })
259
+ .run()
260
+ }
261
+ linkPopoverOpen.value = false
262
+ linkUrl.value = ''
263
+ }
264
+
265
+ function removeLink() {
266
+ if (!editor.value) return
267
+ editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
268
+ linkPopoverOpen.value = false
269
+ linkUrl.value = ''
270
+ }
271
+
272
+ // Image popover handlers
273
+ function applyImage() {
274
+ if (!editor.value || !imageUrl.value.trim()) return
275
+ editor.value
276
+ .chain()
277
+ .focus()
278
+ .setImage({
279
+ src: imageUrl.value.trim(),
280
+ alt: imageAlt.value.trim() || undefined,
281
+ })
282
+ .run()
283
+ imagePopoverOpen.value = false
284
+ imageUrl.value = ''
285
+ imageAlt.value = ''
286
+ }
287
+
288
+ onBeforeUnmount(() => {
289
+ editor.value?.destroy()
290
+ })
291
+
292
+ // Expose editor instance for advanced parent component integrations
293
+ defineExpose({
294
+ editor,
295
+ })
296
+ </script>
297
+
298
+ <template>
299
+ <div
300
+ :class="
301
+ cn(
302
+ 'rounded-xl border border-border bg-card text-card-foreground shadow-2xs transition-all duration-200 flex flex-col overflow-hidden',
303
+ isFocused ? 'ring-2 ring-primary/20 border-primary/50' : 'hover:border-border/80',
304
+ disabled ? 'opacity-60 cursor-not-allowed pointer-events-none' : '',
305
+ props.class,
306
+ )
307
+ "
308
+ >
309
+ <!-- ─── Main Toolbar ────────────────────────────────────────────────── -->
310
+ <div
311
+ v-if="!hideToolbar && editor"
312
+ class="flex flex-wrap items-center gap-0.5 p-1.5 border-b border-border/70 bg-muted/20 backdrop-blur-xs select-none"
313
+ >
314
+ <!-- History Group -->
315
+ <Button
316
+ variant="ghost"
317
+ size="icon"
318
+ type="button"
319
+ class="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
320
+ :disabled="!editor.can().chain().focus().undo().run()"
321
+ title="Undo (Ctrl+Z)"
322
+ @click="editor.chain().focus().undo().run()"
323
+ >
324
+ <RotateCcw class="h-3.5 w-3.5" />
325
+ </Button>
326
+ <Button
327
+ variant="ghost"
328
+ size="icon"
329
+ type="button"
330
+ class="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
331
+ :disabled="!editor.can().chain().focus().redo().run()"
332
+ title="Redo (Ctrl+Y)"
333
+ @click="editor.chain().focus().redo().run()"
334
+ >
335
+ <RotateCw class="h-3.5 w-3.5" />
336
+ </Button>
337
+
338
+ <Separator orientation="vertical" class="h-4 mx-1" />
339
+
340
+ <!-- Typography / Heading Dropdown -->
341
+ <DropdownMenu>
342
+ <DropdownMenuTrigger as-child>
343
+ <Button
344
+ variant="ghost"
345
+ size="sm"
346
+ type="button"
347
+ class="h-7 px-2 text-xs font-medium gap-1 text-muted-foreground hover:text-foreground"
348
+ >
349
+ <span>{{ currentHeadingLabel }}</span>
350
+ </Button>
351
+ </DropdownMenuTrigger>
352
+ <DropdownMenuContent align="start" class="w-36">
353
+ <DropdownMenuItem
354
+ class="gap-2 cursor-pointer text-xs"
355
+ :class="{ 'font-semibold text-primary': editor.isActive('paragraph') }"
356
+ @click="editor.chain().focus().setParagraph().run()"
357
+ >
358
+ <Pilcrow class="h-3.5 w-3.5 text-muted-foreground" />
359
+ <span>Paragraph</span>
360
+ </DropdownMenuItem>
361
+ <DropdownMenuItem
362
+ class="gap-2 cursor-pointer text-xs font-semibold"
363
+ :class="{ 'text-primary': editor.isActive('heading', { level: 1 }) }"
364
+ @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
365
+ >
366
+ <Heading1 class="h-3.5 w-3.5 text-muted-foreground" />
367
+ <span>Heading 1</span>
368
+ </DropdownMenuItem>
369
+ <DropdownMenuItem
370
+ class="gap-2 cursor-pointer text-xs font-semibold"
371
+ :class="{ 'text-primary': editor.isActive('heading', { level: 2 }) }"
372
+ @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
373
+ >
374
+ <Heading2 class="h-3.5 w-3.5 text-muted-foreground" />
375
+ <span>Heading 2</span>
376
+ </DropdownMenuItem>
377
+ <DropdownMenuItem
378
+ class="gap-2 cursor-pointer text-xs font-semibold"
379
+ :class="{ 'text-primary': editor.isActive('heading', { level: 3 }) }"
380
+ @click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
381
+ >
382
+ <Heading3 class="h-3.5 w-3.5 text-muted-foreground" />
383
+ <span>Heading 3</span>
384
+ </DropdownMenuItem>
385
+ </DropdownMenuContent>
386
+ </DropdownMenu>
387
+
388
+ <Separator orientation="vertical" class="h-4 mx-1" />
389
+
390
+ <!-- Inline Text Formats -->
391
+ <Toggle
392
+ size="sm"
393
+ class="h-7 w-7 p-0 rounded-lg"
394
+ :pressed="editor.isActive('bold')"
395
+ title="Bold (Ctrl+B)"
396
+ @click="editor.chain().focus().toggleBold().run()"
397
+ >
398
+ <Bold class="h-3.5 w-3.5" />
399
+ </Toggle>
400
+
401
+ <Toggle
402
+ size="sm"
403
+ class="h-7 w-7 p-0 rounded-lg"
404
+ :pressed="editor.isActive('italic')"
405
+ title="Italic (Ctrl+I)"
406
+ @click="editor.chain().focus().toggleItalic().run()"
407
+ >
408
+ <Italic class="h-3.5 w-3.5" />
409
+ </Toggle>
410
+
411
+ <Toggle
412
+ size="sm"
413
+ class="h-7 w-7 p-0 rounded-lg"
414
+ :pressed="editor.isActive('underline')"
415
+ title="Underline (Ctrl+U)"
416
+ @click="editor.chain().focus().toggleUnderline().run()"
417
+ >
418
+ <UnderlineIcon class="h-3.5 w-3.5" />
419
+ </Toggle>
420
+
421
+ <Toggle
422
+ size="sm"
423
+ class="h-7 w-7 p-0 rounded-lg"
424
+ :pressed="editor.isActive('strike')"
425
+ title="Strikethrough (Ctrl+Shift+X)"
426
+ @click="editor.chain().focus().toggleStrike().run()"
427
+ >
428
+ <Strikethrough class="h-3.5 w-3.5" />
429
+ </Toggle>
430
+
431
+ <Toggle
432
+ size="sm"
433
+ class="h-7 w-7 p-0 rounded-lg"
434
+ :pressed="editor.isActive('code')"
435
+ title="Inline Code"
436
+ @click="editor.chain().focus().toggleCode().run()"
437
+ >
438
+ <Code class="h-3.5 w-3.5" />
439
+ </Toggle>
440
+
441
+ <Separator orientation="vertical" class="h-4 mx-1" />
442
+
443
+ <!-- Block Elements -->
444
+ <Toggle
445
+ size="sm"
446
+ class="h-7 w-7 p-0 rounded-lg"
447
+ :pressed="editor.isActive('bulletList')"
448
+ title="Bullet List"
449
+ @click="editor.chain().focus().toggleBulletList().run()"
450
+ >
451
+ <List class="h-3.5 w-3.5" />
452
+ </Toggle>
453
+
454
+ <Toggle
455
+ size="sm"
456
+ class="h-7 w-7 p-0 rounded-lg"
457
+ :pressed="editor.isActive('orderedList')"
458
+ title="Numbered List"
459
+ @click="editor.chain().focus().toggleOrderedList().run()"
460
+ >
461
+ <ListOrdered class="h-3.5 w-3.5" />
462
+ </Toggle>
463
+
464
+ <Toggle
465
+ size="sm"
466
+ class="h-7 w-7 p-0 rounded-lg"
467
+ :pressed="editor.isActive('blockquote')"
468
+ title="Blockquote"
469
+ @click="editor.chain().focus().toggleBlockquote().run()"
470
+ >
471
+ <Quote class="h-3.5 w-3.5" />
472
+ </Toggle>
473
+
474
+ <Toggle
475
+ size="sm"
476
+ class="h-7 w-7 p-0 rounded-lg"
477
+ :pressed="editor.isActive('codeBlock')"
478
+ title="Code Block"
479
+ @click="editor.chain().focus().toggleCodeBlock().run()"
480
+ >
481
+ <Code2 class="h-3.5 w-3.5" />
482
+ </Toggle>
483
+
484
+ <Button
485
+ variant="ghost"
486
+ size="icon"
487
+ type="button"
488
+ class="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
489
+ title="Horizontal Divider"
490
+ @click="editor.chain().focus().setHorizontalRule().run()"
491
+ >
492
+ <Minus class="h-3.5 w-3.5" />
493
+ </Button>
494
+
495
+ <Separator orientation="vertical" class="h-4 mx-1" />
496
+
497
+ <!-- Link Inserter Popover -->
498
+ <Popover v-model:open="linkPopoverOpen">
499
+ <PopoverTrigger as-child>
500
+ <Toggle
501
+ size="sm"
502
+ class="h-7 w-7 p-0 rounded-lg"
503
+ :pressed="editor.isActive('link')"
504
+ title="Insert Link"
505
+ @click="openLinkPopover"
506
+ >
507
+ <LinkIcon class="h-3.5 w-3.5" />
508
+ </Toggle>
509
+ </PopoverTrigger>
510
+ <PopoverContent class="w-80 p-3 space-y-3" align="start">
511
+ <div class="space-y-1">
512
+ <h4 class="text-xs font-semibold leading-none">Hyperlink</h4>
513
+ <p class="text-[11px] text-muted-foreground">Add a URL to the selected text</p>
514
+ </div>
515
+ <div class="space-y-2">
516
+ <Label class="text-xs">Destination URL</Label>
517
+ <Input
518
+ v-model="linkUrl"
519
+ placeholder="https://example.com"
520
+ class="h-8 text-xs font-mono"
521
+ @keydown.enter.prevent="applyLink"
522
+ />
523
+ </div>
524
+ <div class="flex items-center justify-between pt-1">
525
+ <Button
526
+ v-if="editor.isActive('link')"
527
+ variant="ghost"
528
+ size="sm"
529
+ class="h-7 text-xs text-destructive hover:text-destructive px-2"
530
+ @click="removeLink"
531
+ >
532
+ <Unlink class="h-3 w-3 mr-1" />
533
+ Remove
534
+ </Button>
535
+ <div class="flex items-center gap-1.5 ml-auto">
536
+ <Button
537
+ variant="outline"
538
+ size="sm"
539
+ class="h-7 text-xs px-2.5"
540
+ @click="linkPopoverOpen = false"
541
+ >
542
+ Cancel
543
+ </Button>
544
+ <Button
545
+ size="sm"
546
+ class="h-7 text-xs px-2.5 gap-1"
547
+ @click="applyLink"
548
+ >
549
+ <Check class="h-3 w-3" />
550
+ Apply
551
+ </Button>
552
+ </div>
553
+ </div>
554
+ </PopoverContent>
555
+ </Popover>
556
+
557
+ <!-- Image Inserter Popover -->
558
+ <Popover v-model:open="imagePopoverOpen">
559
+ <PopoverTrigger as-child>
560
+ <Button
561
+ variant="ghost"
562
+ size="icon"
563
+ type="button"
564
+ class="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
565
+ title="Insert Image by URL"
566
+ >
567
+ <ImageIcon class="h-3.5 w-3.5" />
568
+ </Button>
569
+ </PopoverTrigger>
570
+ <PopoverContent class="w-80 p-3 space-y-3" align="start">
571
+ <div class="space-y-1">
572
+ <h4 class="text-xs font-semibold leading-none">Embed Image</h4>
573
+ <p class="text-[11px] text-muted-foreground">Insert an image via external URL</p>
574
+ </div>
575
+ <div class="space-y-2">
576
+ <Label class="text-xs">Image URL</Label>
577
+ <Input
578
+ v-model="imageUrl"
579
+ placeholder="https://images.unsplash.com/..."
580
+ class="h-8 text-xs font-mono"
581
+ @keydown.enter.prevent="applyImage"
582
+ />
583
+ </div>
584
+ <div class="space-y-2">
585
+ <Label class="text-xs">Alt Description (Optional)</Label>
586
+ <Input
587
+ v-model="imageAlt"
588
+ placeholder="Descriptive image caption"
589
+ class="h-8 text-xs"
590
+ @keydown.enter.prevent="applyImage"
591
+ />
592
+ </div>
593
+ <div class="flex items-center justify-end gap-1.5 pt-1">
594
+ <Button
595
+ variant="outline"
596
+ size="sm"
597
+ class="h-7 text-xs px-2.5"
598
+ @click="imagePopoverOpen = false"
599
+ >
600
+ Cancel
601
+ </Button>
602
+ <Button
603
+ size="sm"
604
+ class="h-7 text-xs px-2.5 gap-1"
605
+ :disabled="!imageUrl.trim()"
606
+ @click="applyImage"
607
+ >
608
+ <Check class="h-3 w-3" />
609
+ Insert
610
+ </Button>
611
+ </div>
612
+ </PopoverContent>
613
+ </Popover>
614
+
615
+ <Button
616
+ variant="ghost"
617
+ size="icon"
618
+ type="button"
619
+ class="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive transition-colors ml-auto"
620
+ title="Clear Formatting"
621
+ @click="editor.chain().focus().unsetAllMarks().clearNodes().run()"
622
+ >
623
+ <RemoveFormatting class="h-3.5 w-3.5" />
624
+ </Button>
625
+ </div>
626
+
627
+ <!-- ─── Editor Editable Canvas ──────────────────────────────────────── -->
628
+ <EditorContent
629
+ :editor="editor"
630
+ class="flex-1 cursor-text"
631
+ @click="editor?.commands.focus()"
632
+ />
633
+
634
+ <!-- ─── Character / Word Count & Status Footer ──────────────────────── -->
635
+ <div
636
+ v-if="!hideFooter"
637
+ class="flex items-center justify-between px-3 py-1.5 border-t border-border/60 bg-muted/10 text-[11px] text-muted-foreground select-none"
638
+ >
639
+ <div class="flex items-center gap-2">
640
+ <span class="font-medium">{{ wordCount }} words</span>
641
+ <span class="text-border">•</span>
642
+ <span :class="isOverLimit ? 'text-destructive font-semibold' : ''">
643
+ {{ charCount }}<template v-if="maxLength !== undefined"> / {{ maxLength }}</template> characters
644
+ </span>
645
+ </div>
646
+
647
+ <div class="flex items-center gap-1.5">
648
+ <span
649
+ v-if="isFocused"
650
+ class="inline-flex items-center gap-1 text-primary text-[10px] font-medium"
651
+ >
652
+ <span class="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
653
+ Editing
654
+ </span>
655
+ <span v-else class="text-[10px]">Ready</span>
656
+ </div>
657
+ </div>
658
+ </div>
659
+ </template>
660
+
661
+ <style>
662
+ /* ProseMirror Empty Placeholder styling */
663
+ .ProseMirror p.is-editor-empty:first-child::before {
664
+ content: attr(data-placeholder);
665
+ float: left;
666
+ color: var(--color-muted-foreground);
667
+ opacity: 0.5;
668
+ pointer-events: none;
669
+ height: 0;
670
+ }
671
+
672
+ /* ProseMirror Focus Outline reset */
673
+ .ProseMirror:focus {
674
+ outline: none;
675
+ }
676
+ </style>
@@ -0,0 +1,2 @@
1
+ export { default as RichTextEditor } from './RichTextEditor.vue'
2
+ export type { EditorChangePayload } from './RichTextEditor.vue'
@@ -45,3 +45,4 @@ export * from './toggle'
45
45
  export * from './toggle-group'
46
46
  export * from './tooltip'
47
47
  export * from './chart'
48
+ export * from './editor'