tiptapify 0.3.5 → 0.4.0

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.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +34 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +114 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +8 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. package/src/utils/validateHref.ts +47 -7
@@ -0,0 +1,60 @@
1
+ import { InjectionKey, inject, ref, Ref, watch } from 'vue'
2
+ import { checkLinkPreview, fetchLinkPreview } from '@tiptapify/extensions/components/media/linkCard/linkPreview'
3
+ import { TiptapifyLinkPreviewData, TiptapifyLinkPreviewOptions } from '@tiptapify/types/editor'
4
+
5
+ export type TiptapifyLinkPreviewState = {
6
+ options: Ref<TiptapifyLinkPreviewOptions | null>,
7
+ // The endpoint answered the handshake: link cards can be made
8
+ available: Ref<boolean>,
9
+ }
10
+
11
+ export const TIPTAPIFY_LINK_PREVIEW_KEY: InjectionKey<TiptapifyLinkPreviewState> = Symbol('tiptapifyLinkPreview')
12
+
13
+ /**
14
+ * The `linkPreview` prop of the editor, checked by the handshake whenever its endpoint changes.
15
+ */
16
+ export function createLinkPreviewState(options: Ref<TiptapifyLinkPreviewOptions | null>): TiptapifyLinkPreviewState {
17
+ const available = ref(false)
18
+
19
+ watch(() => options.value?.endpoint, async () => {
20
+ const current = options.value
21
+ available.value = false
22
+
23
+ if (current?.endpoint) {
24
+ const ok = await checkLinkPreview(current)
25
+ // An answer for an endpoint changed meanwhile is stale
26
+ if (options.value?.endpoint === current.endpoint) {
27
+ available.value = ok
28
+ }
29
+ }
30
+ }, { immediate: true })
31
+
32
+ return { options, available }
33
+ }
34
+
35
+ /**
36
+ * Link cards in the dialogs and the paste menu: whether they can be made, the data of a page, and a new
37
+ * handshake for an endpoint that failed (asked when the link dialog opens: it may be up by now).
38
+ */
39
+ export function useTiptapifyLinkPreview(): {
40
+ available: Ref<boolean>,
41
+ fetchPreview: (url: string, signal?: AbortSignal) => Promise<TiptapifyLinkPreviewData>,
42
+ recheck: () => Promise<void>,
43
+ } {
44
+ const { options, available } = inject(TIPTAPIFY_LINK_PREVIEW_KEY, { options: ref(null), available: ref(false) })
45
+
46
+ function fetchPreview(url: string, signal?: AbortSignal): Promise<TiptapifyLinkPreviewData> {
47
+ return options.value
48
+ ? fetchLinkPreview(options.value, url, signal)
49
+ : Promise.reject(new Error('The editor has no linkPreview endpoint'))
50
+ }
51
+
52
+ async function recheck(): Promise<void> {
53
+ const current = options.value
54
+ if (current?.endpoint && !available.value) {
55
+ available.value = await checkLinkPreview(current, { recheckFailed: true })
56
+ }
57
+ }
58
+
59
+ return { available, fetchPreview, recheck }
60
+ }
@@ -0,0 +1,96 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { createApp, h, provide, ref } from 'vue'
3
+ import { fakeUploadEndpoint } from '@tiptapify/composables/fakeUploadEndpoint'
4
+ import { TIPTAPIFY_UPLOAD_KEY, useTiptapifyUpload } from '@tiptapify/composables/useTiptapifyUpload'
5
+ import { TiptapifyUploadOptions } from '@tiptapify/types/editor'
6
+
7
+ function withUpload(upload: TiptapifyUploadOptions | null) {
8
+ let result!: ReturnType<typeof useTiptapifyUpload>
9
+ const Probe = { setup: () => { result = useTiptapifyUpload(); return () => null } }
10
+ createApp({ setup: () => { provide(TIPTAPIFY_UPLOAD_KEY, ref(upload)); return () => h(Probe) } })
11
+ .mount(document.createElement('div'))
12
+
13
+ return result
14
+ }
15
+
16
+ const context = (kind: 'image' | 'video', onProgress = vi.fn(), signal = new AbortController().signal) => ({ kind, onProgress, signal })
17
+
18
+ afterEach(() => {
19
+ vi.unstubAllGlobals()
20
+ })
21
+
22
+ describe('useTiptapifyUpload', () => {
23
+ it('embeds an image as a base64 data URL when the editor has no upload endpoint', async () => {
24
+ const { canUploadImage, canUploadVideo, upload, uploadFile } = withUpload(null)
25
+ const onProgress = vi.fn()
26
+
27
+ const { url } = await uploadFile(new File(['hi'], 'a.png', { type: 'image/png' }), context('image', onProgress))
28
+
29
+ expect(canUploadImage.value).toBe(true)
30
+ expect(canUploadVideo.value).toBe(false)
31
+ expect(upload.value).toBeNull()
32
+ expect(url).toBe('data:image/png;base64,aGk=')
33
+ expect(onProgress).toHaveBeenLastCalledWith(1)
34
+ })
35
+
36
+ it('does not embed a video file: it needs the upload endpoint', async () => {
37
+ const { uploadFile } = withUpload(null)
38
+
39
+ await expect(uploadFile(new File(['v'], 'a.mp4', { type: 'video/mp4' }), context('video'))).rejects.toThrow()
40
+ })
41
+
42
+ it('stops reading the file when the upload is cancelled', async () => {
43
+ const { uploadFile } = withUpload(null)
44
+ const controller = new AbortController()
45
+ controller.abort()
46
+
47
+ await expect(uploadFile(new File(['hi'], 'a.png'), context('image', vi.fn(), controller.signal))).rejects.toThrow()
48
+ })
49
+
50
+ it('posts images and videos to the upload endpoint with its headers and gives the URL it answers', async () => {
51
+ const requests = fakeUploadEndpoint(() => ({ status: 201, body: { url: 'https://cdn.example.com/clip.mp4' } }))
52
+ const { canUploadVideo, upload, uploadFile } = withUpload({ endpoint: '/api/uploads', headers: { Authorization: 'Bearer t' } })
53
+ const file = new File(['v'], 'clip.mp4', { type: 'video/mp4' })
54
+ const onProgress = vi.fn()
55
+
56
+ expect(await uploadFile(file, context('video', onProgress))).toEqual({ url: 'https://cdn.example.com/clip.mp4' })
57
+
58
+ expect(canUploadVideo.value).toBe(true)
59
+ expect(upload.value).toBeTypeOf('function')
60
+ expect(requests).toHaveLength(1)
61
+ expect(requests[0]).toMatchObject({ method: 'POST', url: '/api/uploads', headers: { Accept: 'application/json', Authorization: 'Bearer t' } })
62
+ expect(requests[0].body.get('file')).toBeInstanceOf(File)
63
+ expect((requests[0].body.get('file') as File).name).toBe('clip.mp4')
64
+ expect(requests[0].body.get('kind')).toBe('video')
65
+ expect(onProgress.mock.calls).toEqual([[0.5], [1]])
66
+ })
67
+
68
+ it('rejects with the error the endpoint answers, or with an empty message and the status', async () => {
69
+ fakeUploadEndpoint(request => (request.body.get('kind') === 'image'
70
+ ? { status: 413, body: { error: 'The file is too large.' } }
71
+ : { status: 500, body: 'Internal Server Error' }))
72
+ const { uploadFile } = withUpload({ endpoint: '/api/uploads' })
73
+
74
+ await expect(uploadFile(new File(['hi'], 'a.png'), context('image'))).rejects.toThrow('The file is too large.')
75
+ await expect(uploadFile(new File(['v'], 'a.mp4'), context('video'))).rejects.toMatchObject({ message: '', status: 500 })
76
+ })
77
+
78
+ it('rejects an answer without a url', async () => {
79
+ fakeUploadEndpoint(() => ({ status: 200, body: { path: 'a.png' } }))
80
+ const { uploadFile } = withUpload({ endpoint: '/api/uploads' })
81
+
82
+ await expect(uploadFile(new File(['hi'], 'a.png'), context('image'))).rejects.toMatchObject({ message: '', status: 200 })
83
+ })
84
+
85
+ it('aborts the request when the upload is cancelled', async () => {
86
+ const requests = fakeUploadEndpoint(() => ({ status: 200, body: { url: '/a.png' } }))
87
+ const { uploadFile } = withUpload({ endpoint: '/api/uploads' })
88
+ const controller = new AbortController()
89
+
90
+ const pending = uploadFile(new File(['hi'], 'a.png'), context('image', vi.fn(), controller.signal))
91
+ controller.abort()
92
+
93
+ await expect(pending).rejects.toThrow('cancelled')
94
+ expect(requests).toHaveLength(1)
95
+ })
96
+ })
@@ -0,0 +1,126 @@
1
+ import { computed, ComputedRef, inject, InjectionKey, Ref, ref } from 'vue'
2
+ import { TiptapifyUpload, TiptapifyUploadContext, TiptapifyUploadOptions } from '@tiptapify/types/editor'
3
+
4
+ export const TIPTAPIFY_UPLOAD_KEY: InjectionKey<Ref<TiptapifyUploadOptions | null>> = Symbol('tiptapifyUpload')
5
+
6
+ function cancelled(): DOMException {
7
+ return new DOMException('The upload was cancelled', 'AbortError')
8
+ }
9
+
10
+ /**
11
+ * A picked image as a base64 data URL, for an editor without an upload endpoint.
12
+ */
13
+ function readAsDataUrl(file: File, { onProgress, signal }: TiptapifyUploadContext): Promise<{ url: string }> {
14
+ return new Promise((resolve, reject) => {
15
+ if (signal.aborted) {
16
+ reject(cancelled())
17
+
18
+ return
19
+ }
20
+
21
+ const reader = new FileReader()
22
+ reader.onprogress = event => event.lengthComputable && onProgress(event.loaded / event.total)
23
+ reader.onload = () => {
24
+ onProgress(1)
25
+ resolve({ url: String(reader.result) })
26
+ }
27
+ reader.onerror = () => reject(reader.error ?? new Error('The file could not be read'))
28
+ reader.onabort = () => reject(cancelled())
29
+ signal.addEventListener('abort', () => reader.abort(), { once: true })
30
+ reader.readAsDataURL(file)
31
+ })
32
+ }
33
+
34
+ function parseJson(text: string): Record<string, unknown> | null {
35
+ try {
36
+ return JSON.parse(text)
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ /**
43
+ * XMLHttpRequest, not fetch: it reports the progress of the upload itself. A failure carries the endpoint's
44
+ * `error` as its message, else an empty one: the dialogs then show their own translated message.
45
+ */
46
+ function sendToEndpoint(
47
+ { endpoint, headers }: TiptapifyUploadOptions,
48
+ file: File,
49
+ { kind, onProgress, signal }: TiptapifyUploadContext,
50
+ ): Promise<{ url: string }> {
51
+ return new Promise((resolve, reject) => {
52
+ if (signal.aborted) {
53
+ reject(cancelled())
54
+
55
+ return
56
+ }
57
+
58
+ const request = new XMLHttpRequest()
59
+ request.open('POST', endpoint)
60
+ request.setRequestHeader('Accept', 'application/json')
61
+ for (const [name, value] of Object.entries(headers ?? {})) {
62
+ request.setRequestHeader(name, value)
63
+ }
64
+
65
+ request.upload.onprogress = event => event.lengthComputable && onProgress(event.loaded / event.total)
66
+ request.onload = () => {
67
+ const body = parseJson(request.responseText)
68
+ if (request.status >= 200 && request.status < 300 && typeof body?.url === 'string' && body.url) {
69
+ onProgress(1)
70
+ resolve({ url: body.url })
71
+
72
+ return
73
+ }
74
+
75
+ reject(Object.assign(new Error(typeof body?.error === 'string' ? body.error : ''), { status: request.status }))
76
+ }
77
+ request.onerror = () => reject(Object.assign(new Error(''), { status: 0 }))
78
+ request.onabort = () => reject(cancelled())
79
+ signal.addEventListener('abort', () => request.abort(), { once: true })
80
+
81
+ const form = new FormData()
82
+ form.append('file', file)
83
+ form.append('kind', kind)
84
+ request.send(form)
85
+ })
86
+ }
87
+
88
+ /**
89
+ * Files picked in the dialogs. With the `upload` endpoint of the editor they are sent to it and the document
90
+ * keeps their URL. Without it an image (a video poster, the image of a link card) is embedded as base64, and
91
+ * a video file can't be picked: a document would grow by its whole size.
92
+ */
93
+ export function useTiptapifyUpload(): {
94
+ // null without an upload endpoint
95
+ upload: ComputedRef<TiptapifyUpload | null>,
96
+ canUploadImage: ComputedRef<boolean>,
97
+ canUploadVideo: ComputedRef<boolean>,
98
+ uploadFile: TiptapifyUpload,
99
+ } {
100
+ const options = inject(TIPTAPIFY_UPLOAD_KEY, ref(null))
101
+
102
+ const upload = computed<TiptapifyUpload | null>(() => {
103
+ const current = options.value
104
+
105
+ return current?.endpoint ? (file, context) => sendToEndpoint(current, file, context) : null
106
+ })
107
+
108
+ function uploadFile(file: File, context: TiptapifyUploadContext): Promise<{ url: string }> {
109
+ if (upload.value) {
110
+ return upload.value(file, context)
111
+ }
112
+
113
+ if (context.kind === 'image') {
114
+ return readAsDataUrl(file, context)
115
+ }
116
+
117
+ return Promise.reject(new Error('A video file needs the upload endpoint of the editor'))
118
+ }
119
+
120
+ return {
121
+ upload,
122
+ canUploadImage: computed(() => true),
123
+ canUploadVideo: computed(() => upload.value !== null),
124
+ uploadFile,
125
+ }
126
+ }
@@ -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
+ })