tiptapify 0.3.5 → 0.4.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/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110770 -65623
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +37 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +210 -0
- package/src/extensions/components/media/image/imageDialog.ts +117 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +10 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +47 -2
- package/src/utils/validateHref.ts +47 -7
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { App, computed, createApp, h, nextTick, provide, ref, shallowRef } from 'vue'
|
|
3
|
+
import { createVuetify } from 'vuetify'
|
|
4
|
+
import { Editor } from '@tiptap/vue-3'
|
|
5
|
+
import { Bold } from '@tiptap/extension-bold'
|
|
6
|
+
import { Document } from '@tiptap/extension-document'
|
|
7
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
8
|
+
import { Text } from '@tiptap/extension-text'
|
|
9
|
+
import { CharacterCount } from '@tiptap/extensions'
|
|
10
|
+
import AiDialog from '@tiptapify/extensions/components/ai/Dialog.vue'
|
|
11
|
+
import { TiptapifyAiOptions, TiptapifyAiProvider } from '@tiptapify/types/editor'
|
|
12
|
+
|
|
13
|
+
// jsdom has no `visualViewport`; Vuetify's overlay positioning reads it
|
|
14
|
+
if (!('visualViewport' in globalThis)) {
|
|
15
|
+
;(globalThis as Record<string, unknown>).visualViewport = {
|
|
16
|
+
scale: 1,
|
|
17
|
+
offsetLeft: 0,
|
|
18
|
+
offsetTop: 0,
|
|
19
|
+
addEventListener: () => {},
|
|
20
|
+
removeEventListener: () => {},
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Nor `ResizeObserver`, which the auto-growing prompt textarea reads
|
|
25
|
+
if (!('ResizeObserver' in globalThis)) {
|
|
26
|
+
;(globalThis as Record<string, unknown>).ResizeObserver = class {
|
|
27
|
+
observe() {}
|
|
28
|
+
unobserve() {}
|
|
29
|
+
disconnect() {}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let app: App | null = null
|
|
34
|
+
let editor: Editor | null = null
|
|
35
|
+
|
|
36
|
+
async function flush() {
|
|
37
|
+
for (let i = 0; i < 5; i++) {
|
|
38
|
+
await nextTick()
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Setup = {
|
|
43
|
+
content: string,
|
|
44
|
+
selection?: { from: number, to: number },
|
|
45
|
+
options?: TiptapifyAiOptions,
|
|
46
|
+
limit?: number,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function openDialog({ content, selection, options = {}, limit }: Setup): Promise<HTMLElement> {
|
|
50
|
+
editor = new Editor({
|
|
51
|
+
element: document.createElement('div'),
|
|
52
|
+
content,
|
|
53
|
+
extensions: [Document, Paragraph, Text, Bold, ...(limit ? [CharacterCount.configure({ limit })] : [])],
|
|
54
|
+
})
|
|
55
|
+
if (selection) {
|
|
56
|
+
editor.commands.setTextSelection(selection)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const dialog = ref<{ showDialog: () => void } | null>(null)
|
|
60
|
+
app = createApp({
|
|
61
|
+
setup() {
|
|
62
|
+
provide('tiptapifyEditor', shallowRef(editor))
|
|
63
|
+
provide('tiptapifyI18n', { t: (key: string) => key })
|
|
64
|
+
provide('tiptapifyAi', computed(() => ({ enabled: true, promptExamples: [], ...options })))
|
|
65
|
+
|
|
66
|
+
return () => h(AiDialog, { ref: dialog })
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
app.use(createVuetify())
|
|
70
|
+
app.mount(document.body.appendChild(document.createElement('div')))
|
|
71
|
+
await flush()
|
|
72
|
+
|
|
73
|
+
dialog.value!.showDialog()
|
|
74
|
+
await flush()
|
|
75
|
+
|
|
76
|
+
return document.querySelector<HTMLElement>('.tiptapify-dialog')!
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function typePrompt(card: HTMLElement, value: string) {
|
|
80
|
+
const textarea = card.querySelector('textarea')!
|
|
81
|
+
textarea.value = value
|
|
82
|
+
textarea.dispatchEvent(new Event('input'))
|
|
83
|
+
await flush()
|
|
84
|
+
|
|
85
|
+
return textarea
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function pressEnter(textarea: HTMLTextAreaElement, shiftKey = false) {
|
|
89
|
+
const event = new KeyboardEvent('keydown', { key: 'Enter', shiftKey, bubbles: true, cancelable: true })
|
|
90
|
+
textarea.dispatchEvent(event)
|
|
91
|
+
await flush()
|
|
92
|
+
|
|
93
|
+
return event
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buttonWithText(card: HTMLElement, text: string): HTMLButtonElement {
|
|
97
|
+
return [...card.querySelectorAll('button')].find(button => button.textContent?.trim() === text) as HTMLButtonElement
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
app?.unmount()
|
|
102
|
+
editor?.destroy()
|
|
103
|
+
app = null
|
|
104
|
+
editor = null
|
|
105
|
+
document.body.innerHTML = ''
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('ai dialog', () => {
|
|
109
|
+
it('generates on Enter for the selection and replaces it with the formatted result', async () => {
|
|
110
|
+
const aiProvider = vi.fn<TiptapifyAiProvider>(async (_request, _context, stream) => {
|
|
111
|
+
stream!.onChunk('<b>there</b>')
|
|
112
|
+
|
|
113
|
+
return ''
|
|
114
|
+
})
|
|
115
|
+
const card = await openDialog({ content: '<p>Hello world</p>', selection: { from: 7, to: 12 }, options: { aiProvider } })
|
|
116
|
+
|
|
117
|
+
expect(card.querySelector('.ai-context')!.textContent).toContain('ai.context.selection')
|
|
118
|
+
expect(buttonWithText(card, 'ai.replace_selection').disabled).toBe(true)
|
|
119
|
+
|
|
120
|
+
const textarea = await typePrompt(card, 'Make it friendly')
|
|
121
|
+
const event = await pressEnter(textarea)
|
|
122
|
+
|
|
123
|
+
expect(event.defaultPrevented).toBe(true)
|
|
124
|
+
expect(aiProvider).toHaveBeenCalledTimes(1)
|
|
125
|
+
const [request, context] = aiProvider.mock.calls[0]
|
|
126
|
+
expect(context).toMatchObject({ prompt: 'Make it friendly', selectedText: 'world', mode: 'replace' })
|
|
127
|
+
expect(request.enable_thinking).toBe(false)
|
|
128
|
+
expect(card.querySelector('.ai-result__content')!.innerHTML).toBe('<strong>there</strong>')
|
|
129
|
+
|
|
130
|
+
card.querySelector('form')!.requestSubmit()
|
|
131
|
+
await flush()
|
|
132
|
+
|
|
133
|
+
expect(editor!.getHTML()).toBe('<p>Hello <strong>there</strong></p>')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('sends a selection cut inside a word as whole words, with the text around it', async () => {
|
|
137
|
+
const aiProvider = vi.fn<TiptapifyAiProvider>(async () => 'have entered a new')
|
|
138
|
+
const text = 'The talks has entered a new stage.'
|
|
139
|
+
const card = await openDialog({
|
|
140
|
+
content: `<p>${text}</p>`,
|
|
141
|
+
selection: { from: 1 + text.indexOf('as entered'), to: 1 + text.indexOf('ew stage') },
|
|
142
|
+
options: { aiProvider },
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
await pressEnter(await typePrompt(card, 'Fix grammar'))
|
|
146
|
+
|
|
147
|
+
const [request, context] = aiProvider.mock.calls[0]
|
|
148
|
+
expect(context).toMatchObject({ selectedText: 'has entered a new', textBefore: 'The talks ', textAfter: ' stage.' })
|
|
149
|
+
const message = request.messages.at(-1)!.content as string
|
|
150
|
+
expect(message).toContain('Text before the selection:\nThe talks ')
|
|
151
|
+
expect(message).toContain('Selected text:\nhas entered a new')
|
|
152
|
+
expect(message).toContain('Text after the selection:\n stage.')
|
|
153
|
+
expect(message).toContain('User request:\nFix grammar')
|
|
154
|
+
|
|
155
|
+
card.querySelector('form')!.requestSubmit()
|
|
156
|
+
await flush()
|
|
157
|
+
|
|
158
|
+
expect(editor!.getHTML()).toBe('<p>The talks have entered a new stage.</p>')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('inserts the result below the selection, keeping it', async () => {
|
|
162
|
+
const aiProvider: TiptapifyAiProvider = async () => 'Added'
|
|
163
|
+
const card = await openDialog({ content: '<p>Hello world</p>', selection: { from: 1, to: 6 }, options: { aiProvider } })
|
|
164
|
+
|
|
165
|
+
await pressEnter(await typePrompt(card, 'Continue'))
|
|
166
|
+
buttonWithText(card, 'ai.insert_below').click()
|
|
167
|
+
await flush()
|
|
168
|
+
|
|
169
|
+
expect(editor!.getHTML()).toBe('<p>Hello world</p><p>Added</p>')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('inserts at the cursor or appends to the end without a selection', async () => {
|
|
173
|
+
const aiProvider: TiptapifyAiProvider = async () => ({ content: '<p>End</p>' })
|
|
174
|
+
const card = await openDialog({ content: '<p>Hello</p><p>World</p>', options: { aiProvider } })
|
|
175
|
+
|
|
176
|
+
expect(card.querySelector('.ai-context')!.textContent).toContain('ai.context.document')
|
|
177
|
+
expect(buttonWithText(card, 'ai.insert')).toBeTruthy()
|
|
178
|
+
|
|
179
|
+
await pressEnter(await typePrompt(card, 'Write an ending'))
|
|
180
|
+
buttonWithText(card, 'ai.append').click()
|
|
181
|
+
await flush()
|
|
182
|
+
|
|
183
|
+
expect(editor!.getHTML()).toBe('<p>Hello</p><p>World</p><p>End</p>')
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('keeps Shift+Enter as a new line', async () => {
|
|
187
|
+
const aiProvider = vi.fn<TiptapifyAiProvider>(async () => 'x')
|
|
188
|
+
const card = await openDialog({ content: '<p></p>', options: { aiProvider } })
|
|
189
|
+
|
|
190
|
+
const event = await pressEnter(await typePrompt(card, 'Line'), true)
|
|
191
|
+
|
|
192
|
+
expect(event.defaultPrevented).toBe(false)
|
|
193
|
+
expect(aiProvider).not.toHaveBeenCalled()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('stops a streaming request and keeps the partial result without an error', async () => {
|
|
197
|
+
const aiProvider: TiptapifyAiProvider = (_request, _context, stream) => new Promise((_resolve, reject) => {
|
|
198
|
+
stream!.onChunk('Partial')
|
|
199
|
+
stream!.signal.addEventListener('abort', () => reject(new Error('aborted')))
|
|
200
|
+
})
|
|
201
|
+
const card = await openDialog({ content: '<p></p>', options: { aiProvider } })
|
|
202
|
+
|
|
203
|
+
await pressEnter(await typePrompt(card, 'Write'))
|
|
204
|
+
expect(card.querySelector('.ai-send--stop')).not.toBeNull()
|
|
205
|
+
|
|
206
|
+
card.querySelector<HTMLButtonElement>('.ai-send--stop')!.click()
|
|
207
|
+
await flush()
|
|
208
|
+
|
|
209
|
+
expect(card.querySelector('.ai-send--stop')).toBeNull()
|
|
210
|
+
expect(card.querySelector('.ai-result__content')!.textContent).toBe('Partial')
|
|
211
|
+
expect(card.querySelector('.ai-status')).toBeNull()
|
|
212
|
+
expect(buttonWithText(card, 'ai.insert').disabled).toBe(false)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('explains a request the browser could not complete (service down or CORS) instead of "Failed to fetch"', async () => {
|
|
216
|
+
const aiProvider: TiptapifyAiProvider = async () => {
|
|
217
|
+
throw new TypeError('Failed to fetch')
|
|
218
|
+
}
|
|
219
|
+
const card = await openDialog({ content: '<p></p>', options: { aiProvider } })
|
|
220
|
+
|
|
221
|
+
await pressEnter(await typePrompt(card, 'Write'))
|
|
222
|
+
|
|
223
|
+
const status = card.querySelector('.ai-status--error')!.textContent!
|
|
224
|
+
expect(status).toContain('ai.errors.network')
|
|
225
|
+
expect(status).not.toContain('Failed to fetch')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('explains an error status and keeps the message of the service', async () => {
|
|
229
|
+
const aiProvider: TiptapifyAiProvider = async () => {
|
|
230
|
+
throw Object.assign(new Error('missing or wrong API key'), { status: 401 })
|
|
231
|
+
}
|
|
232
|
+
const card = await openDialog({ content: '<p></p>', options: { aiProvider } })
|
|
233
|
+
|
|
234
|
+
await pressEnter(await typePrompt(card, 'Write'))
|
|
235
|
+
|
|
236
|
+
const status = card.querySelector('.ai-status--error')!.textContent!
|
|
237
|
+
expect(status).toContain('ai.errors.auth')
|
|
238
|
+
expect(status).toContain('missing or wrong API key')
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it('shows a provider error inline', async () => {
|
|
242
|
+
const aiProvider: TiptapifyAiProvider = async () => {
|
|
243
|
+
throw new Error('Quota exceeded')
|
|
244
|
+
}
|
|
245
|
+
const card = await openDialog({ content: '<p></p>', options: { aiProvider } })
|
|
246
|
+
|
|
247
|
+
await pressEnter(await typePrompt(card, 'Write'))
|
|
248
|
+
|
|
249
|
+
expect(card.querySelector('.ai-status--error')!.textContent).toContain('Quota exceeded')
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('blocks a result over the character limit', async () => {
|
|
253
|
+
const aiProvider: TiptapifyAiProvider = async () => 'x'.repeat(20)
|
|
254
|
+
const card = await openDialog({ content: '<p>Hello</p>', options: { aiProvider }, limit: 10 })
|
|
255
|
+
|
|
256
|
+
await pressEnter(await typePrompt(card, 'Write'))
|
|
257
|
+
|
|
258
|
+
expect(card.querySelector('.ai-status--error')!.textContent).toContain('ai.limit_exceeded')
|
|
259
|
+
expect(card.querySelector('.ai-result__counter')!.textContent).toBe('25 / 10')
|
|
260
|
+
expect(buttonWithText(card, 'ai.insert').disabled).toBe(true)
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
it('says when no provider is configured', async () => {
|
|
264
|
+
const card = await openDialog({ content: '<p></p>' })
|
|
265
|
+
|
|
266
|
+
expect(card.querySelector('.ai-status--warning')!.textContent).toContain('ai.unavailable')
|
|
267
|
+
expect(card.querySelector('textarea')!.disabled).toBe(true)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('sends the configured default effort and shows the reasoning collapsed', async () => {
|
|
271
|
+
const aiProvider = vi.fn<TiptapifyAiProvider>(async (_request, _context, stream) => {
|
|
272
|
+
stream!.onReasoning('Considering')
|
|
273
|
+
stream!.onChunk('Done')
|
|
274
|
+
|
|
275
|
+
return ''
|
|
276
|
+
})
|
|
277
|
+
const card = await openDialog({
|
|
278
|
+
content: '<p></p>',
|
|
279
|
+
options: { aiProvider, thinking: true, showReasoning: true, reasoningEffort: { options: ['low', 'high'], default: 'high' } },
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
await pressEnter(await typePrompt(card, 'Write'))
|
|
283
|
+
|
|
284
|
+
expect(aiProvider.mock.calls[0][0]).toMatchObject({ reasoning_effort: 'high' })
|
|
285
|
+
expect(aiProvider.mock.calls[0][0]).not.toHaveProperty('enable_thinking')
|
|
286
|
+
expect(card.querySelector('.ai-reasoning__toggle')!.textContent).toContain('ai.thought_for')
|
|
287
|
+
expect(card.querySelector('.ai-reasoning__text')).toBeNull()
|
|
288
|
+
|
|
289
|
+
card.querySelector<HTMLButtonElement>('.ai-reasoning__toggle')!.click()
|
|
290
|
+
await flush()
|
|
291
|
+
|
|
292
|
+
expect(card.querySelector('.ai-reasoning__text')!.textContent).toBe('Considering')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('turns thinking off from the one compact control', async () => {
|
|
296
|
+
const aiProvider = vi.fn<TiptapifyAiProvider>(async () => 'Done')
|
|
297
|
+
const card = await openDialog({ content: '<p></p>', options: { aiProvider, thinking: true } })
|
|
298
|
+
const control = card.querySelector<HTMLButtonElement>('.ai-thinking')!
|
|
299
|
+
|
|
300
|
+
expect(control.textContent).toContain('ai.thinking_label.on')
|
|
301
|
+
|
|
302
|
+
control.click()
|
|
303
|
+
await flush()
|
|
304
|
+
const off = [...document.querySelectorAll<HTMLElement>('.v-list-item')].find(item => item.textContent?.includes('ai.thinking_states.off'))!
|
|
305
|
+
off.click()
|
|
306
|
+
await flush()
|
|
307
|
+
|
|
308
|
+
expect(control.textContent).toContain('ai.thinking_label.off')
|
|
309
|
+
|
|
310
|
+
await pressEnter(await typePrompt(card, 'Write'))
|
|
311
|
+
|
|
312
|
+
expect(aiProvider.mock.calls[0][0]).toMatchObject({ enable_thinking: false })
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('shows the word changes against the selection and regenerates another version', async () => {
|
|
316
|
+
let version = 0
|
|
317
|
+
const aiProvider = vi.fn<TiptapifyAiProvider>(async () => (++version === 1 ? 'Hello there' : 'Hi there'))
|
|
318
|
+
const card = await openDialog({ content: '<p>Hello world</p>', selection: { from: 1, to: 12 }, options: { aiProvider } })
|
|
319
|
+
|
|
320
|
+
await pressEnter(await typePrompt(card, 'Rewrite'))
|
|
321
|
+
buttonWithText(card, 'ai.changes').click()
|
|
322
|
+
await flush()
|
|
323
|
+
|
|
324
|
+
expect(card.querySelector('.ai-diff__delete')!.textContent).toBe('world')
|
|
325
|
+
expect(card.querySelector('.ai-diff__insert')!.textContent).toBe('there')
|
|
326
|
+
|
|
327
|
+
await typePrompt(card, 'Something else')
|
|
328
|
+
buttonWithText(card, 'ai.another_version').click()
|
|
329
|
+
await flush()
|
|
330
|
+
|
|
331
|
+
expect(aiProvider).toHaveBeenCalledTimes(2)
|
|
332
|
+
expect(aiProvider.mock.calls[1][1].prompt).toBe('Rewrite')
|
|
333
|
+
expect(card.querySelector('.ai-result__content')!.textContent).toBe('Hi there')
|
|
334
|
+
})
|
|
335
|
+
})
|