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 +1 -1
- package/template/.agents/skills/nala-project/SKILL.md +42 -1
- package/template/README.md +1 -1
- package/template/package.json +8 -1
- package/template/src/components/ui/editor/RichTextEditor.vue +676 -0
- package/template/src/components/ui/editor/index.ts +2 -0
- package/template/src/components/ui/index.ts +1 -0
package/package.json
CHANGED
|
@@ -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) |
|
|
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
|
package/template/README.md
CHANGED
|
@@ -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/ #
|
|
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
|
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "NALA_APP_SLUG",
|
|
3
|
-
"version": "3.1.
|
|
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>
|