create-nala 3.0.4 → 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 +258 -70
- package/template/AGENTS.md +1 -1
- package/template/CLAUDE.md +27 -0
- package/template/GEMINI.md +27 -0
- package/template/README.md +3 -1
- package/template/package.json +8 -1
- package/template/src/App.vue +1 -1
- package/template/src/assets/fonts/InterVariable.woff2 +0 -0
- package/template/src/assets/fonts/inter-v20-latin-300.woff2 +0 -0
- package/template/src/assets/fonts/inter-v20-latin-500.woff2 +0 -0
- package/template/src/assets/fonts/inter-v20-latin-600.woff2 +0 -0
- package/template/src/assets/fonts/inter-v20-latin-700.woff2 +0 -0
- package/template/src/assets/fonts/inter-v20-latin-regular.woff2 +0 -0
- package/template/src/assets/hero.png +0 -0
- package/template/src/assets/vite.svg +1 -0
- package/template/src/assets/vue.svg +1 -0
- 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/template/src/components/ui/sonner/Sonner.vue +43 -13
- package/template/tsconfig.app.json +17 -11
|
@@ -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>
|