Skip to content

Commit 8156634

Browse files
docs(editor): add ai completion example (#5665)
1 parent feb756d commit 8156634

File tree

19 files changed

+1080
-301
lines changed

19 files changed

+1080
-301
lines changed

docs/app/components/content/ComponentExample.vue

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ const props = withDefaults(defineProps<{
6464
*/
6565
elevated?: boolean
6666
lang?: string
67+
/**
68+
* Override the filename used for the code block
69+
*/
70+
filename?: string
6771
}>(), {
6872
preview: true,
6973
source: true,
@@ -96,7 +100,7 @@ const code = computed(() => {
96100
`
97101
}
98102
99-
code += `\`\`\`${props.lang} ${props.preview ? '' : ` [${data.pascalName}.${props.lang}]`}${props.highlights?.length ? `{${props.highlights.join('-')}}` : ''}
103+
code += `\`\`\`${props.lang} ${props.preview ? '' : ` [${props.filename ?? data.pascalName}.${props.lang}]`}${props.highlights?.length ? `{${props.highlights.join('-')}}` : ''}
100104
${data?.code ?? ''}
101105
\`\`\``
102106
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
<script setup lang="ts">
2+
import type { EditorCustomHandlers, EditorToolbarItem } from '@nuxt/ui'
3+
import { useEditorCompletion } from './EditorUseCompletion'
4+
5+
const editorRef = useTemplateRef('editorRef')
6+
7+
const value = ref(`# AI Completion
8+
9+
This editor demonstrates how to add AI-powered features using the Vercel AI SDK. It includes ghost text autocompletion that appears as you type (press Tab to accept) and text transformation actions.
10+
11+
Try selecting some text and using the AI dropdown to fix grammar, extend, or simplify it.`)
12+
13+
const { extension: completionExtension, handlers: aiHandlers, isLoading: aiLoading } = useEditorCompletion(editorRef)
14+
15+
const customHandlers = {
16+
...aiHandlers
17+
} satisfies EditorCustomHandlers
18+
19+
const items = computed(() => [[{
20+
icon: 'i-lucide-sparkles',
21+
label: 'Improve',
22+
variant: 'soft',
23+
loading: aiLoading.value,
24+
content: {
25+
align: 'start'
26+
},
27+
items: [{
28+
kind: 'aiFix',
29+
icon: 'i-lucide-spell-check',
30+
label: 'Fix spelling & grammar'
31+
}, {
32+
kind: 'aiExtend',
33+
icon: 'i-lucide-unfold-vertical',
34+
label: 'Extend text'
35+
}, {
36+
kind: 'aiReduce',
37+
icon: 'i-lucide-fold-vertical',
38+
label: 'Reduce text'
39+
}, {
40+
kind: 'aiSimplify',
41+
icon: 'i-lucide-lightbulb',
42+
label: 'Simplify text'
43+
}, {
44+
kind: 'aiContinue',
45+
icon: 'i-lucide-text',
46+
label: 'Continue sentence'
47+
}, {
48+
kind: 'aiSummarize',
49+
icon: 'i-lucide-list',
50+
label: 'Summarize'
51+
}, {
52+
icon: 'i-lucide-languages',
53+
label: 'Translate',
54+
children: [{
55+
kind: 'aiTranslate',
56+
language: 'English',
57+
label: 'English'
58+
}, {
59+
kind: 'aiTranslate',
60+
language: 'French',
61+
label: 'French'
62+
}, {
63+
kind: 'aiTranslate',
64+
language: 'Spanish',
65+
label: 'Spanish'
66+
}, {
67+
kind: 'aiTranslate',
68+
language: 'German',
69+
label: 'German'
70+
}]
71+
}]
72+
}], [{
73+
icon: 'i-lucide-heading',
74+
content: {
75+
align: 'start'
76+
},
77+
items: [{
78+
kind: 'heading',
79+
level: 1,
80+
icon: 'i-lucide-heading-1',
81+
label: 'Heading 1'
82+
}, {
83+
kind: 'heading',
84+
level: 2,
85+
icon: 'i-lucide-heading-2',
86+
label: 'Heading 2'
87+
}, {
88+
kind: 'heading',
89+
level: 3,
90+
icon: 'i-lucide-heading-3',
91+
label: 'Heading 3'
92+
}]
93+
}], [{
94+
kind: 'mark',
95+
mark: 'bold',
96+
icon: 'i-lucide-bold'
97+
}, {
98+
kind: 'mark',
99+
mark: 'italic',
100+
icon: 'i-lucide-italic'
101+
}, {
102+
kind: 'mark',
103+
mark: 'underline',
104+
icon: 'i-lucide-underline'
105+
}]] satisfies EditorToolbarItem<typeof customHandlers>[][])
106+
</script>
107+
108+
<template>
109+
<UEditor
110+
ref="editorRef"
111+
v-slot="{ editor }"
112+
v-model="value"
113+
:extensions="[completionExtension]"
114+
:handlers="customHandlers"
115+
content-type="markdown"
116+
:ui="{ base: 'p-8 sm:px-16' }"
117+
class="w-full min-h-74"
118+
>
119+
<UEditorToolbar :editor="editor" :items="items" class="border-b border-muted py-2 px-8 sm:px-16 overflow-x-auto" />
120+
</UEditor>
121+
</template>
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { Extension } from '@tiptap/core'
2+
import { Decoration, DecorationSet } from '@tiptap/pm/view'
3+
import { Plugin, PluginKey } from '@tiptap/pm/state'
4+
import type { Editor } from '@tiptap/vue-3'
5+
import { useDebounceFn } from '@vueuse/core'
6+
7+
export interface CompletionOptions {
8+
/**
9+
* Debounce delay in ms before triggering completion
10+
* @defaultValue 250
11+
*/
12+
debounce?: number
13+
/**
14+
* Characters that should prevent completion from triggering
15+
* @defaultValue ['/', ':', '@']
16+
*/
17+
triggerCharacters?: string[]
18+
/**
19+
* Called when completion should be triggered, receives the text before cursor
20+
*/
21+
onTrigger?: (textBefore: string) => void
22+
/**
23+
* Called when suggestion is accepted
24+
*/
25+
onAccept?: () => void
26+
/**
27+
* Called when suggestion is dismissed
28+
*/
29+
onDismiss?: () => void
30+
}
31+
32+
export interface CompletionStorage {
33+
suggestion: string
34+
position: number | undefined
35+
visible: boolean
36+
debouncedTrigger: ((editor: Editor) => void) | null
37+
setSuggestion: (text: string) => void
38+
clearSuggestion: () => void
39+
}
40+
41+
export const completionPluginKey = new PluginKey('completion')
42+
43+
export const Completion = Extension.create<CompletionOptions, CompletionStorage>({
44+
name: 'completion',
45+
46+
addOptions() {
47+
return {
48+
debounce: 250,
49+
triggerCharacters: ['/', ':', '@'],
50+
onTrigger: undefined,
51+
onAccept: undefined,
52+
onDismiss: undefined
53+
}
54+
},
55+
56+
addStorage() {
57+
return {
58+
suggestion: '',
59+
position: undefined as number | undefined,
60+
visible: false,
61+
debouncedTrigger: null as ((editor: Editor) => void) | null,
62+
setSuggestion(text: string) {
63+
this.suggestion = text
64+
},
65+
clearSuggestion() {
66+
this.suggestion = ''
67+
this.position = undefined
68+
this.visible = false
69+
}
70+
}
71+
},
72+
73+
addProseMirrorPlugins() {
74+
const storage = this.storage
75+
76+
return [
77+
new Plugin({
78+
key: completionPluginKey,
79+
props: {
80+
decorations(state) {
81+
if (!storage.visible || !storage.suggestion || storage.position === undefined) {
82+
return DecorationSet.empty
83+
}
84+
85+
const widget = Decoration.widget(storage.position, () => {
86+
const span = document.createElement('span')
87+
span.className = 'completion-suggestion'
88+
span.textContent = storage.suggestion
89+
span.style.cssText = 'color: var(--ui-text-muted); opacity: 0.6; pointer-events: none;'
90+
return span
91+
}, { side: 1 })
92+
93+
return DecorationSet.create(state.doc, [widget])
94+
}
95+
}
96+
})
97+
]
98+
},
99+
100+
addKeyboardShortcuts() {
101+
return {
102+
Tab: ({ editor }) => {
103+
if (!this.storage.visible || !this.storage.suggestion || this.storage.position === undefined) {
104+
return false
105+
}
106+
107+
// Store values before clearing
108+
const suggestion = this.storage.suggestion
109+
const position = this.storage.position
110+
111+
// Clear suggestion first
112+
this.storage.clearSuggestion()
113+
114+
// Force decoration update
115+
editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
116+
117+
// Insert the suggestion text
118+
editor.chain().focus().insertContentAt(position, suggestion).run()
119+
120+
this.options.onAccept?.()
121+
return true
122+
},
123+
Escape: ({ editor }) => {
124+
if (this.storage.visible) {
125+
this.storage.clearSuggestion()
126+
// Force decoration update
127+
editor.view.dispatch(editor.state.tr.setMeta('completionUpdate', true))
128+
this.options.onDismiss?.()
129+
return true
130+
}
131+
return false
132+
}
133+
}
134+
},
135+
136+
onUpdate({ editor }) {
137+
// Clear suggestion on any edit
138+
if (this.storage.visible) {
139+
this.storage.clearSuggestion()
140+
this.options.onDismiss?.()
141+
}
142+
143+
// Debounced trigger check
144+
this.storage.debouncedTrigger?.(editor as unknown as Editor)
145+
},
146+
147+
onSelectionUpdate() {
148+
if (this.storage.visible) {
149+
this.storage.clearSuggestion()
150+
this.options.onDismiss?.()
151+
}
152+
},
153+
154+
onCreate() {
155+
const storage = this.storage
156+
const options = this.options
157+
158+
// Create debounced trigger function for this instance
159+
this.storage.debouncedTrigger = useDebounceFn((editor: Editor) => {
160+
if (!options.onTrigger) return
161+
162+
const { state } = editor
163+
const { selection } = state
164+
const { $from } = selection
165+
166+
// Only suggest at end of block with content
167+
const isAtEndOfBlock = $from.parentOffset === $from.parent.content.size
168+
const hasContent = $from.parent.textContent.trim().length > 0
169+
const textContent = $from.parent.textContent
170+
171+
// Don't trigger if sentence is complete (ends with punctuation)
172+
const endsWithPunctuation = /[.!?]\s*$/.test(textContent)
173+
174+
// Don't trigger if text ends with trigger characters
175+
const triggerChars = options.triggerCharacters || []
176+
const endsWithTrigger = triggerChars.some(char => textContent.endsWith(char))
177+
178+
if (!isAtEndOfBlock || !hasContent || endsWithPunctuation || endsWithTrigger) {
179+
return
180+
}
181+
182+
// Set position and mark as visible
183+
storage.position = selection.from
184+
storage.visible = true
185+
186+
// Get text before cursor as context
187+
const textBefore = state.doc.textBetween(0, selection.from, '\n')
188+
options.onTrigger(textBefore)
189+
}, options.debounce || 250)
190+
},
191+
192+
onDestroy() {
193+
this.storage.debouncedTrigger = null
194+
}
195+
})
196+
197+
export default Completion

0 commit comments

Comments
 (0)