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,65 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Tests only
|
|
4
|
+
|
|
5
|
+
export type FakeUploadRequest = {
|
|
6
|
+
method: string,
|
|
7
|
+
url: string,
|
|
8
|
+
headers: Record<string, string>,
|
|
9
|
+
body: FormData,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type FakeUploadAnswer = { status: number, body: unknown }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Replaces XMLHttpRequest with one that records each request and answers it on the next task: first half of
|
|
16
|
+
* the upload progress, then `answer(request)`. Restored by `vi.unstubAllGlobals()`.
|
|
17
|
+
*/
|
|
18
|
+
export function fakeUploadEndpoint(answer: (request: FakeUploadRequest) => FakeUploadAnswer): FakeUploadRequest[] {
|
|
19
|
+
const requests: FakeUploadRequest[] = []
|
|
20
|
+
|
|
21
|
+
class FakeXMLHttpRequest {
|
|
22
|
+
upload: { onprogress: ((event: { lengthComputable: boolean, loaded: number, total: number }) => void) | null } = { onprogress: null }
|
|
23
|
+
onload: (() => void) | null = null
|
|
24
|
+
onerror: (() => void) | null = null
|
|
25
|
+
onabort: (() => void) | null = null
|
|
26
|
+
status = 0
|
|
27
|
+
responseText = ''
|
|
28
|
+
private request: FakeUploadRequest = { method: '', url: '', headers: {}, body: new FormData() }
|
|
29
|
+
private aborted = false
|
|
30
|
+
|
|
31
|
+
open(method: string, url: string) {
|
|
32
|
+
Object.assign(this.request, { method, url })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setRequestHeader(name: string, value: string) {
|
|
36
|
+
this.request.headers[name] = value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
send(body: FormData) {
|
|
40
|
+
this.request.body = body
|
|
41
|
+
requests.push(this.request)
|
|
42
|
+
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
if (this.aborted) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.upload.onprogress?.({ lengthComputable: true, loaded: 1, total: 2 })
|
|
49
|
+
const { status, body: response } = answer(this.request)
|
|
50
|
+
this.status = status
|
|
51
|
+
this.responseText = typeof response === 'string' ? response : JSON.stringify(response)
|
|
52
|
+
this.onload?.()
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
abort() {
|
|
57
|
+
this.aborted = true
|
|
58
|
+
this.onabort?.()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest)
|
|
63
|
+
|
|
64
|
+
return requests
|
|
65
|
+
}
|
|
@@ -15,6 +15,24 @@ export interface TiptapifyConfig {
|
|
|
15
15
|
// css variable name -> value; only overrides, the css fallbacks carry the base
|
|
16
16
|
tokens: ComputedRef<Record<string, string>>
|
|
17
17
|
shape: ComputedRef<Required<TiptapifyShape>>
|
|
18
|
+
// the skin and shape classes of the editor root
|
|
19
|
+
rootClasses: ComputedRef<string[]>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The classes of the editor root: the skin name class (the escape hatch for consumer CSS) and
|
|
24
|
+
* `tiptapify-shape-{flag}-{value}` for all six shape flags, which the skin CSS gates on.
|
|
25
|
+
*/
|
|
26
|
+
export function skinRootClasses(className: string, shape: Required<TiptapifyShape>): string[] {
|
|
27
|
+
return [
|
|
28
|
+
className,
|
|
29
|
+
`tiptapify-shape-toolbar-${shape.toolbar}`,
|
|
30
|
+
`tiptapify-shape-groups-${shape.groups}`,
|
|
31
|
+
`tiptapify-shape-buttons-${shape.buttons}`,
|
|
32
|
+
`tiptapify-shape-bubble-${shape.bubble}`,
|
|
33
|
+
`tiptapify-shape-dialog-${shape.dialog}`,
|
|
34
|
+
`tiptapify-shape-menu-${shape.menu}`,
|
|
35
|
+
]
|
|
18
36
|
}
|
|
19
37
|
|
|
20
38
|
export const TIPTAPIFY_CONFIG_KEY: InjectionKey<TiptapifyConfig> = Symbol('tiptapifyConfig')
|
|
@@ -32,5 +50,6 @@ export function useTiptapifyConfig(): TiptapifyConfig {
|
|
|
32
50
|
skinName: computed(() => defaultSkin.name),
|
|
33
51
|
tokens: computed(() => defaultSkin.tokens),
|
|
34
52
|
shape: computed(() => defaultSkin.shape),
|
|
53
|
+
rootClasses: computed(() => skinRootClasses(defaultSkin.className, defaultSkin.shape)),
|
|
35
54
|
})
|
|
36
55
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { nextTick, ref } from 'vue'
|
|
3
|
+
import { createLinkPreviewState } from '@tiptapify/composables/useTiptapifyLinkPreview'
|
|
4
|
+
import { resetLinkPreviewChecks } from '@tiptapify/extensions/components/media/linkCard/linkPreview'
|
|
5
|
+
import { TiptapifyLinkPreviewOptions } from '@tiptapify/types/editor'
|
|
6
|
+
|
|
7
|
+
const flush = () => new Promise(resolve => setTimeout(resolve))
|
|
8
|
+
|
|
9
|
+
describe('linkPreview prop', () => {
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals()
|
|
12
|
+
resetLinkPreviewChecks()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('makes link cards available once the endpoint answers the handshake', async () => {
|
|
16
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(JSON.stringify({ tiptapifyLinkPreview: 1 })))))
|
|
17
|
+
|
|
18
|
+
const { available } = createLinkPreviewState(ref({ endpoint: '/api/link-preview' }))
|
|
19
|
+
expect(available.value).toBe(false)
|
|
20
|
+
|
|
21
|
+
await flush()
|
|
22
|
+
expect(available.value).toBe(true)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('keeps link cards off without an endpoint, and asks nothing', async () => {
|
|
26
|
+
const fetch = vi.fn()
|
|
27
|
+
vi.stubGlobal('fetch', fetch)
|
|
28
|
+
|
|
29
|
+
const { available } = createLinkPreviewState(ref(null))
|
|
30
|
+
await flush()
|
|
31
|
+
|
|
32
|
+
expect(available.value).toBe(false)
|
|
33
|
+
expect(fetch).not.toHaveBeenCalled()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('checks a new endpoint when the prop changes', async () => {
|
|
37
|
+
vi.stubGlobal('fetch', vi.fn((url: string) => Promise.resolve(
|
|
38
|
+
new Response(url === '/ok' ? JSON.stringify({ tiptapifyLinkPreview: 1 }) : 'nope', { status: url === '/ok' ? 200 : 404 }),
|
|
39
|
+
)))
|
|
40
|
+
const options = ref<TiptapifyLinkPreviewOptions | null>({ endpoint: '/missing' })
|
|
41
|
+
|
|
42
|
+
const { available } = createLinkPreviewState(options)
|
|
43
|
+
await flush()
|
|
44
|
+
expect(available.value).toBe(false)
|
|
45
|
+
|
|
46
|
+
options.value = { endpoint: '/ok' }
|
|
47
|
+
await nextTick()
|
|
48
|
+
await flush()
|
|
49
|
+
expect(available.value).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -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
|
+
}
|