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,87 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/core'
|
|
3
|
+
import { Document } from '@tiptap/extension-document'
|
|
4
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
5
|
+
import { Text } from '@tiptap/extension-text'
|
|
6
|
+
import Link from '@tiptap/extension-link'
|
|
7
|
+
import { LinkCard } from '@tiptapify/extensions/components/media/linkCard'
|
|
8
|
+
import {
|
|
9
|
+
isLinkCardAddress,
|
|
10
|
+
linkCardAddressProblem,
|
|
11
|
+
linkCardToLink,
|
|
12
|
+
} from '@tiptapify/extensions/components/media/linkCard/linkCardDialog'
|
|
13
|
+
|
|
14
|
+
let editor: Editor | null = null
|
|
15
|
+
|
|
16
|
+
const cardHtml = '<a class="tiptapify-link-card" data-link-card="compact" href="https://news.example.com/a" target="_blank" rel="noopener noreferrer">'
|
|
17
|
+
+ '<span class="tiptapify-link-card__body"><span class="tiptapify-link-card__title">Talks</span></span></a>'
|
|
18
|
+
|
|
19
|
+
function createEditor(content: string): Editor {
|
|
20
|
+
editor = new Editor({
|
|
21
|
+
element: document.createElement('div'),
|
|
22
|
+
content,
|
|
23
|
+
extensions: [Document, Paragraph, Text, Link.configure({ openOnClick: false }), LinkCard],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
return editor
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
editor?.destroy()
|
|
31
|
+
editor = null
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('link card address', () => {
|
|
35
|
+
it.each([
|
|
36
|
+
'https://news.example.com/a?b=1#c',
|
|
37
|
+
'http://example.org',
|
|
38
|
+
'https://localhost:8080/page',
|
|
39
|
+
'http://127.0.0.1/x',
|
|
40
|
+
])('takes the complete http(s) address %s', (address) => {
|
|
41
|
+
expect(isLinkCardAddress(address)).toBe(true)
|
|
42
|
+
expect(linkCardAddressProblem(address)).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('asks for the protocol of an address without one and never adds it itself', () => {
|
|
46
|
+
expect(linkCardAddressProblem('example.com/page')).toBe('protocol')
|
|
47
|
+
expect(linkCardAddressProblem('localhost:8080')).toBe('protocol')
|
|
48
|
+
expect(linkCardAddressProblem('example')).toBe('protocol')
|
|
49
|
+
expect(isLinkCardAddress('example.com')).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('rejects other protocols and incomplete addresses, and says nothing about an empty one', () => {
|
|
53
|
+
expect(linkCardAddressProblem('ftp://example.com/file')).toBe('invalid')
|
|
54
|
+
expect(linkCardAddressProblem('mailto:a@example.com')).toBe('invalid')
|
|
55
|
+
expect(linkCardAddressProblem('https://exam')).toBe('invalid')
|
|
56
|
+
expect(linkCardAddressProblem('https://exa mple.com')).toBe('invalid')
|
|
57
|
+
expect(linkCardAddressProblem(' ')).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('show a card as a link', () => {
|
|
62
|
+
it('replaces the selected card with a paragraph linking its page under its title', () => {
|
|
63
|
+
createEditor(`<p>Before</p>${cardHtml}<p>After</p>`)
|
|
64
|
+
editor!.commands.setNodeSelection(8)
|
|
65
|
+
|
|
66
|
+
expect(linkCardToLink(editor!, { href: 'https://news.example.com/a', title: 'Talks', newTab: true })).toBe(true)
|
|
67
|
+
expect(editor!.getHTML()).toBe(
|
|
68
|
+
'<p>Before</p><p><a target="_blank" rel="noopener noreferrer" href="https://news.example.com/a">Talks</a></p><p>After</p>',
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('uses the address as the text of a card without a title and opens it in the same tab when asked', () => {
|
|
73
|
+
createEditor(cardHtml)
|
|
74
|
+
editor!.commands.setNodeSelection(0)
|
|
75
|
+
|
|
76
|
+
linkCardToLink(editor!, { href: 'https://news.example.com/a', title: ' ', newTab: false })
|
|
77
|
+
|
|
78
|
+
expect(editor!.getHTML()).toBe('<p><a href="https://news.example.com/a">https://news.example.com/a</a></p>')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('does nothing without a selected card', () => {
|
|
82
|
+
createEditor('<p>Text</p>')
|
|
83
|
+
|
|
84
|
+
expect(linkCardToLink(editor!, { href: 'https://a.example', title: 'A', newTab: true })).toBe(false)
|
|
85
|
+
expect(editor!.getHTML()).toBe('<p>Text</p>')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { Editor } from '@tiptap/core'
|
|
2
|
+
import { LinkCardAttrs, LinkCardLayout, selectedLinkCard } from '@tiptapify/extensions/components/media/linkCard'
|
|
3
|
+
import { LINK_MARK, linkAttrs, LinkForm, missingProtocol } from '@tiptapify/extensions/components/media/link/linkDialog'
|
|
4
|
+
|
|
5
|
+
export const LINK_CARD_LAYOUTS: LinkCardLayout[] = ['compact', 'large', 'text']
|
|
6
|
+
|
|
7
|
+
// What is wrong with the address of a card: no protocol (the author adds it, the dialog never guesses it),
|
|
8
|
+
// or not a complete http(s) address of a page
|
|
9
|
+
export type LinkCardAddressProblem = 'protocol' | 'invalid'
|
|
10
|
+
|
|
11
|
+
const schemeRegex = /^[a-z][a-z0-9+.-]*:/i
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A complete http(s) address of a page: only such an address is asked from the link preview endpoint.
|
|
15
|
+
*/
|
|
16
|
+
export function isLinkCardAddress(value: string): boolean {
|
|
17
|
+
const href = (value ?? '').trim()
|
|
18
|
+
|
|
19
|
+
if (!/^https?:\/\//i.test(href) || /\s/.test(href)) {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const { hostname } = new URL(href)
|
|
25
|
+
|
|
26
|
+
return hostname === 'localhost' || /\.[a-z0-9-]{2,}$/i.test(hostname) || /^\d+(\.\d+){3}$/.test(hostname)
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function linkCardAddressProblem(value: string): LinkCardAddressProblem | null {
|
|
33
|
+
const href = (value ?? '').trim()
|
|
34
|
+
|
|
35
|
+
if (!href || isLinkCardAddress(href)) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return missingProtocol(href) || !schemeRegex.test(href) ? 'protocol' : 'invalid'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A card displayed as a link again: the selected card becomes a paragraph with a link to its page, the
|
|
44
|
+
* card's title (or the address) as the text, and the rel values and class of the link dialog if given.
|
|
45
|
+
*/
|
|
46
|
+
export function linkCardToLink(
|
|
47
|
+
editor: Editor,
|
|
48
|
+
card: Pick<LinkCardAttrs, 'href' | 'title' | 'newTab'> & Partial<Pick<LinkForm, 'rel' | 'cssClass'>>,
|
|
49
|
+
): boolean {
|
|
50
|
+
if (!selectedLinkCard(editor)) {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const href = card.href.trim()
|
|
55
|
+
const text = card.title?.trim() || href
|
|
56
|
+
const attrs = linkAttrs({ href, text, newTab: card.newTab, rel: card.rel ?? [], cssClass: card.cssClass ?? '' })
|
|
57
|
+
|
|
58
|
+
return editor.chain().focus().insertContent({
|
|
59
|
+
type: 'paragraph',
|
|
60
|
+
content: [{ type: 'text', text, marks: [{ type: LINK_MARK, attrs }] }],
|
|
61
|
+
}).run()
|
|
62
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
checkLinkPreview,
|
|
4
|
+
fetchLinkPreview,
|
|
5
|
+
resetLinkPreviewChecks,
|
|
6
|
+
} from '@tiptapify/extensions/components/media/linkCard/linkPreview'
|
|
7
|
+
|
|
8
|
+
function respond(status: number, body: unknown) {
|
|
9
|
+
return Promise.resolve(new Response(typeof body === 'string' ? body : JSON.stringify(body), { status }))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('link preview endpoint', () => {
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllGlobals()
|
|
15
|
+
resetLinkPreviewChecks()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('is available when the handshake answers the tiptapify contract', async () => {
|
|
19
|
+
const fetch = vi.fn(() => respond(200, { tiptapifyLinkPreview: 1 }))
|
|
20
|
+
vi.stubGlobal('fetch', fetch)
|
|
21
|
+
|
|
22
|
+
expect(await checkLinkPreview({ endpoint: '/api/link-preview', headers: { 'X-Key': 'k' } })).toBe(true)
|
|
23
|
+
expect(fetch).toHaveBeenCalledWith('/api/link-preview', expect.objectContaining({
|
|
24
|
+
method: 'GET',
|
|
25
|
+
headers: expect.objectContaining({ Accept: 'application/json', 'X-Key': 'k' }),
|
|
26
|
+
}))
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it.each([
|
|
30
|
+
['any page answering 200', 200, '<!doctype html><html></html>'],
|
|
31
|
+
['an empty 204', 204, ''],
|
|
32
|
+
['another JSON', 200, { ok: true }],
|
|
33
|
+
['another version of the contract', 200, { tiptapifyLinkPreview: 2 }],
|
|
34
|
+
['an error', 500, { error: 'down' }],
|
|
35
|
+
])('is not available for %s', async (_, status, body) => {
|
|
36
|
+
vi.stubGlobal('fetch', vi.fn(() => respond(status as number, body)))
|
|
37
|
+
|
|
38
|
+
expect(await checkLinkPreview({ endpoint: '/api/link-preview' })).toBe(false)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('is not available when the endpoint can not be reached', async () => {
|
|
42
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('Failed to fetch'))))
|
|
43
|
+
|
|
44
|
+
expect(await checkLinkPreview({ endpoint: '/api/link-preview' })).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('checks an endpoint once for all the editors of the page', async () => {
|
|
48
|
+
const fetch = vi.fn(() => respond(200, { tiptapifyLinkPreview: 1 }))
|
|
49
|
+
vi.stubGlobal('fetch', fetch)
|
|
50
|
+
|
|
51
|
+
await Promise.all([checkLinkPreview({ endpoint: '/a' }), checkLinkPreview({ endpoint: '/a' }), checkLinkPreview({ endpoint: '/a' })])
|
|
52
|
+
|
|
53
|
+
expect(fetch).toHaveBeenCalledTimes(1)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('checks a failed endpoint again when asked: it may be up by now', async () => {
|
|
57
|
+
const fetch = vi.fn()
|
|
58
|
+
.mockReturnValueOnce(respond(503, ''))
|
|
59
|
+
.mockReturnValueOnce(respond(200, { tiptapifyLinkPreview: 1 }))
|
|
60
|
+
vi.stubGlobal('fetch', fetch)
|
|
61
|
+
|
|
62
|
+
expect(await checkLinkPreview({ endpoint: '/a' })).toBe(false)
|
|
63
|
+
expect(await checkLinkPreview({ endpoint: '/a' })).toBe(false)
|
|
64
|
+
expect(await checkLinkPreview({ endpoint: '/a' }, { recheckFailed: true })).toBe(true)
|
|
65
|
+
expect(fetch).toHaveBeenCalledTimes(2)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('gets the preview of a page with the url as a query parameter', async () => {
|
|
69
|
+
const fetch = vi.fn(() => respond(200, {
|
|
70
|
+
url: 'https://news.example.com/a',
|
|
71
|
+
title: ' Title ',
|
|
72
|
+
description: 'Description',
|
|
73
|
+
image: 'https://news.example.com/og.jpg',
|
|
74
|
+
siteName: 'News',
|
|
75
|
+
extra: 'ignored',
|
|
76
|
+
}))
|
|
77
|
+
vi.stubGlobal('fetch', fetch)
|
|
78
|
+
|
|
79
|
+
const preview = await fetchLinkPreview({ endpoint: '/api/link-preview?lang=uk' }, 'https://news.example.com/a?x=1&y=2')
|
|
80
|
+
|
|
81
|
+
expect(fetch.mock.calls[0][0]).toBe('/api/link-preview?lang=uk&url=https%3A%2F%2Fnews.example.com%2Fa%3Fx%3D1%26y%3D2')
|
|
82
|
+
expect(preview).toEqual({
|
|
83
|
+
url: 'https://news.example.com/a',
|
|
84
|
+
title: 'Title',
|
|
85
|
+
description: 'Description',
|
|
86
|
+
image: 'https://news.example.com/og.jpg',
|
|
87
|
+
siteName: 'News',
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('keeps only the fields it got: they are optional', async () => {
|
|
92
|
+
vi.stubGlobal('fetch', vi.fn(() => respond(200, { url: 'https://a.example', title: 'A', image: '' })))
|
|
93
|
+
|
|
94
|
+
expect(await fetchLinkPreview({ endpoint: '/p' }, 'https://a.example')).toEqual({ url: 'https://a.example', title: 'A' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('fails with the error of the endpoint', async () => {
|
|
98
|
+
vi.stubGlobal('fetch', vi.fn(() => respond(422, { error: 'The page does not answer' })))
|
|
99
|
+
|
|
100
|
+
await expect(fetchLinkPreview({ endpoint: '/p' }, 'https://a.example')).rejects.toThrow('The page does not answer')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { TiptapifyLinkPreviewData, TiptapifyLinkPreviewOptions } from '@tiptapify/types/editor'
|
|
2
|
+
|
|
3
|
+
// The version of the endpoint contract: the handshake answers `{ "tiptapifyLinkPreview": 1 }`
|
|
4
|
+
export const LINK_PREVIEW_CONTRACT = 1
|
|
5
|
+
|
|
6
|
+
const PREVIEW_FIELDS = ['title', 'description', 'image', 'siteName'] as const
|
|
7
|
+
|
|
8
|
+
// One handshake per endpoint for all the editors of the page
|
|
9
|
+
const checks = new Map<string, Promise<boolean>>()
|
|
10
|
+
|
|
11
|
+
function requestInit(options: TiptapifyLinkPreviewOptions, signal?: AbortSignal): RequestInit {
|
|
12
|
+
return { method: 'GET', headers: { Accept: 'application/json', ...options.headers }, signal }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function handshake(options: TiptapifyLinkPreviewOptions): Promise<boolean> {
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch(options.endpoint, requestInit(options))
|
|
18
|
+
if (response.status !== 200) {
|
|
19
|
+
return false
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const body = await response.json()
|
|
23
|
+
|
|
24
|
+
return body?.tiptapifyLinkPreview === LINK_PREVIEW_CONTRACT
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Whether the endpoint answers the link preview contract: a GET without a url answers
|
|
32
|
+
* `200 { "tiptapifyLinkPreview": 1 }` and fetches no page. A plain 200 or 204 proves nothing (a SPA
|
|
33
|
+
* fallback or a login page answers 200 too). The answer is kept for the page; a failed endpoint is
|
|
34
|
+
* asked again only on `recheckFailed` (it may be up by now).
|
|
35
|
+
*/
|
|
36
|
+
export async function checkLinkPreview(
|
|
37
|
+
options: TiptapifyLinkPreviewOptions,
|
|
38
|
+
{ recheckFailed = false }: { recheckFailed?: boolean } = {},
|
|
39
|
+
): Promise<boolean> {
|
|
40
|
+
const cached = checks.get(options.endpoint)
|
|
41
|
+
if (cached && !(recheckFailed && !(await cached))) {
|
|
42
|
+
return cached
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const check = handshake(options)
|
|
46
|
+
checks.set(options.endpoint, check)
|
|
47
|
+
|
|
48
|
+
return check
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function resetLinkPreviewChecks(): void {
|
|
52
|
+
checks.clear()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function previewUrl(endpoint: string, url: string): string {
|
|
56
|
+
return `${endpoint}${endpoint.includes('?') ? '&' : '?'}url=${encodeURIComponent(url)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The OpenGraph data of a page: `GET {endpoint}?url=<page>` answers `200 { url, title, description,
|
|
61
|
+
* image, siteName }` (all but `url` optional), or an error with `{ error }`. Empty fields are dropped.
|
|
62
|
+
*/
|
|
63
|
+
export async function fetchLinkPreview(
|
|
64
|
+
options: TiptapifyLinkPreviewOptions,
|
|
65
|
+
url: string,
|
|
66
|
+
signal?: AbortSignal,
|
|
67
|
+
): Promise<TiptapifyLinkPreviewData> {
|
|
68
|
+
const response = await fetch(previewUrl(options.endpoint, url), requestInit(options, signal))
|
|
69
|
+
const body = await response.json().catch(() => null)
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
throw new Error(typeof body?.error === 'string' && body.error ? body.error : `${response.status} ${response.statusText}`.trim())
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const preview: TiptapifyLinkPreviewData = { url: typeof body?.url === 'string' && body.url ? body.url : url }
|
|
76
|
+
for (const field of PREVIEW_FIELDS) {
|
|
77
|
+
const value = typeof body?.[field] === 'string' ? body[field].trim() : ''
|
|
78
|
+
if (value) {
|
|
79
|
+
preview[field] = value
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return preview
|
|
84
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { makeLinkCard } from '@tiptapify/extensions/components/media/linkCard/makeLinkCard'
|
|
3
|
+
|
|
4
|
+
const preview = {
|
|
5
|
+
url: 'https://news.example.com/a',
|
|
6
|
+
title: 'Title',
|
|
7
|
+
description: 'Description',
|
|
8
|
+
image: 'https://cdn.example.com/og.jpg',
|
|
9
|
+
siteName: 'News',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe('makeLinkCard', () => {
|
|
13
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
14
|
+
|
|
15
|
+
it('makes a card of the page data', async () => {
|
|
16
|
+
const card = await makeLinkCard('https://news.example.com/a', { fetchPreview: async () => preview, upload: null })
|
|
17
|
+
|
|
18
|
+
expect(card).toEqual({
|
|
19
|
+
href: 'https://news.example.com/a',
|
|
20
|
+
title: 'Title',
|
|
21
|
+
description: 'Description',
|
|
22
|
+
image: 'https://cdn.example.com/og.jpg',
|
|
23
|
+
siteName: 'News',
|
|
24
|
+
layout: 'compact',
|
|
25
|
+
newTab: true,
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps the address typed by the author, not the one the endpoint resolved', async () => {
|
|
30
|
+
const card = await makeLinkCard('https://news.example.com/a?utm=1', {
|
|
31
|
+
fetchPreview: async () => ({ ...preview, url: 'https://news.example.com/a' }),
|
|
32
|
+
upload: null,
|
|
33
|
+
newTab: false,
|
|
34
|
+
layout: 'large',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
expect(card).toMatchObject({ href: 'https://news.example.com/a?utm=1', newTab: false, layout: 'large' })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('stores the page image with the upload of the editor: a page may change or drop it', async () => {
|
|
41
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve(new Response(new Blob(['png'], { type: 'image/png' })))))
|
|
42
|
+
const upload = vi.fn(async (file: File) => ({ url: `https://media.example.com/${file.name}` }))
|
|
43
|
+
|
|
44
|
+
const card = await makeLinkCard('https://news.example.com/a', { fetchPreview: async () => preview, upload })
|
|
45
|
+
|
|
46
|
+
expect(upload).toHaveBeenCalledWith(expect.any(File), expect.objectContaining({ kind: 'image' }))
|
|
47
|
+
expect(card.image).toBe('https://media.example.com/og.jpg')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('keeps the page image link when the image can not be read or stored', async () => {
|
|
51
|
+
vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('CORS'))))
|
|
52
|
+
const upload = vi.fn()
|
|
53
|
+
|
|
54
|
+
const card = await makeLinkCard('https://news.example.com/a', { fetchPreview: async () => preview, upload })
|
|
55
|
+
|
|
56
|
+
expect(upload).not.toHaveBeenCalled()
|
|
57
|
+
expect(card.image).toBe('https://cdn.example.com/og.jpg')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { LinkCardAttrs, LinkCardLayout } from '@tiptapify/extensions/components/media/linkCard'
|
|
2
|
+
import { TiptapifyLinkPreviewData, TiptapifyUpload } from '@tiptapify/types/editor'
|
|
3
|
+
|
|
4
|
+
export type MakeLinkCardOptions = {
|
|
5
|
+
fetchPreview: (url: string, signal?: AbortSignal) => Promise<TiptapifyLinkPreviewData>,
|
|
6
|
+
// The upload of the editor: the page image is stored with it, else the card links the page's own image
|
|
7
|
+
upload: TiptapifyUpload | null,
|
|
8
|
+
newTab?: boolean,
|
|
9
|
+
layout?: LinkCardLayout,
|
|
10
|
+
signal?: AbortSignal,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function fileName(url: string): string {
|
|
14
|
+
try {
|
|
15
|
+
return decodeURIComponent(new URL(url).pathname.split('/').pop() || '') || 'image'
|
|
16
|
+
} catch {
|
|
17
|
+
return 'image'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The page image stored with the editor's upload: a page may change or drop its image later. A browser
|
|
23
|
+
* reads it only when the image host allows it (CORS); otherwise the card keeps the image link.
|
|
24
|
+
*/
|
|
25
|
+
async function storeImage(image: string, upload: TiptapifyUpload, signal?: AbortSignal): Promise<string> {
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetch(image, { signal })
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
return image
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const blob = await response.blob()
|
|
33
|
+
const file = new File([blob], fileName(image), { type: blob.type })
|
|
34
|
+
const { url } = await upload(file, { kind: 'image', onProgress: () => {}, signal: signal ?? new AbortController().signal })
|
|
35
|
+
|
|
36
|
+
return url
|
|
37
|
+
} catch {
|
|
38
|
+
return image
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A link card of a page: its OpenGraph data from the editor's endpoint, under the address the author typed.
|
|
44
|
+
*/
|
|
45
|
+
export async function makeLinkCard(url: string, options: MakeLinkCardOptions): Promise<LinkCardAttrs> {
|
|
46
|
+
const preview = await options.fetchPreview(url, options.signal)
|
|
47
|
+
const image = preview.image && options.upload ? await storeImage(preview.image, options.upload, options.signal) : preview.image
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
href: url,
|
|
51
|
+
title: preview.title ?? null,
|
|
52
|
+
description: preview.description ?? null,
|
|
53
|
+
image: image ?? null,
|
|
54
|
+
siteName: preview.siteName ?? null,
|
|
55
|
+
layout: options.layout ?? 'compact',
|
|
56
|
+
newTab: options.newTab ?? true,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { Editor } from '@tiptap/core'
|
|
3
|
+
import { Document } from '@tiptap/extension-document'
|
|
4
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
5
|
+
import { Text } from '@tiptap/extension-text'
|
|
6
|
+
import Link from '@tiptap/extension-link'
|
|
7
|
+
import { LinkCard, LinkCardAttrs } from '@tiptapify/extensions/components/media/linkCard'
|
|
8
|
+
import {
|
|
9
|
+
dismissPastedLink,
|
|
10
|
+
LinkCardPaste,
|
|
11
|
+
PASTE_CARD_MENU_CLASS,
|
|
12
|
+
pastedLink,
|
|
13
|
+
pastedLinkUrl,
|
|
14
|
+
replacePastedLink,
|
|
15
|
+
} from '@tiptapify/extensions/components/media/linkCard/pasteCard'
|
|
16
|
+
|
|
17
|
+
const url = 'https://news.example.com/a?b=1'
|
|
18
|
+
|
|
19
|
+
const card: LinkCardAttrs = {
|
|
20
|
+
href: url,
|
|
21
|
+
title: 'Talks enter a new stage',
|
|
22
|
+
description: null,
|
|
23
|
+
image: null,
|
|
24
|
+
siteName: 'News',
|
|
25
|
+
layout: 'compact',
|
|
26
|
+
newTab: true,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let editor: Editor | null = null
|
|
30
|
+
|
|
31
|
+
function createEditor(content: string) {
|
|
32
|
+
editor = new Editor({
|
|
33
|
+
element: document.body.appendChild(document.createElement('div')),
|
|
34
|
+
content,
|
|
35
|
+
extensions: [Document, Paragraph, Text, Link.configure({ openOnClick: false }), LinkCard, LinkCardPaste],
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return editor
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// jsdom has no ClipboardEvent: a plain paste event stands for it
|
|
42
|
+
function paste(ed: Editor, text: string) {
|
|
43
|
+
ed.view.pasteText(text, new Event('paste') as ClipboardEvent)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
editor?.destroy()
|
|
48
|
+
editor = null
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('pastedLinkUrl', () => {
|
|
52
|
+
it.each([
|
|
53
|
+
[url, url],
|
|
54
|
+
[' http://example.com ', 'http://example.com'],
|
|
55
|
+
])('%s is one address', (text, expected) => {
|
|
56
|
+
expect(pastedLinkUrl(text)).toBe(expected)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it.each([
|
|
60
|
+
'example.com',
|
|
61
|
+
'ftp://example.com/file',
|
|
62
|
+
'mailto:user@example.com',
|
|
63
|
+
'see https://example.com',
|
|
64
|
+
'https://example.com https://example.org',
|
|
65
|
+
'https://exa mple.com',
|
|
66
|
+
'',
|
|
67
|
+
null,
|
|
68
|
+
])('%s is not', (text) => {
|
|
69
|
+
expect(pastedLinkUrl(text)).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('the pasted link', () => {
|
|
74
|
+
it('is pasted as usual (a link) and remembered when it is alone in an empty paragraph', () => {
|
|
75
|
+
const ed = createEditor('<p>Before</p><p></p>')
|
|
76
|
+
ed.commands.setTextSelection(9)
|
|
77
|
+
|
|
78
|
+
paste(ed, url)
|
|
79
|
+
|
|
80
|
+
expect(ed.getHTML()).toBe(`<p>Before</p><p><a target="_blank" rel="noopener noreferrer nofollow" href="${url}">${url.replace('&', '&')}</a></p>`)
|
|
81
|
+
expect(pastedLink(ed.state)).toEqual({ url, from: 9, to: 9 + url.length, paragraphFrom: 8, paragraphTo: 10 + url.length })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('is not remembered in a paragraph with text, or for text that is not one address', () => {
|
|
85
|
+
const ed = createEditor('<p>Before</p><p></p>')
|
|
86
|
+
ed.commands.setTextSelection(7)
|
|
87
|
+
paste(ed, url)
|
|
88
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
89
|
+
|
|
90
|
+
ed.commands.setContent('<p></p>')
|
|
91
|
+
ed.commands.setTextSelection(1)
|
|
92
|
+
paste(ed, `see ${url}`)
|
|
93
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
94
|
+
|
|
95
|
+
ed.commands.setContent('<p></p>')
|
|
96
|
+
ed.commands.setTextSelection(1)
|
|
97
|
+
paste(ed, 'example.com')
|
|
98
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('is forgotten on typing', () => {
|
|
102
|
+
const ed = createEditor('<p></p>')
|
|
103
|
+
paste(ed, url)
|
|
104
|
+
|
|
105
|
+
ed.commands.insertContent(' ')
|
|
106
|
+
|
|
107
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('is forgotten when the selection moves', () => {
|
|
111
|
+
const ed = createEditor('<p>Before</p><p></p>')
|
|
112
|
+
ed.commands.setTextSelection(9)
|
|
113
|
+
paste(ed, url)
|
|
114
|
+
|
|
115
|
+
ed.commands.setTextSelection(2)
|
|
116
|
+
|
|
117
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('is forgotten on Esc and on dismiss', () => {
|
|
121
|
+
const ed = createEditor('<p></p>')
|
|
122
|
+
paste(ed, url)
|
|
123
|
+
|
|
124
|
+
const escape = new KeyboardEvent('keydown', { key: 'Escape' })
|
|
125
|
+
expect(ed.view.someProp('handleKeyDown', handler => handler(ed.view, escape))).toBe(true)
|
|
126
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
127
|
+
// Esc does nothing more without a pasted link
|
|
128
|
+
expect(ed.view.someProp('handleKeyDown', handler => handler(ed.view, escape))).toBeFalsy()
|
|
129
|
+
|
|
130
|
+
ed.commands.setContent('<p></p>')
|
|
131
|
+
paste(ed, url)
|
|
132
|
+
dismissPastedLink(ed)
|
|
133
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
134
|
+
expect(ed.getHTML()).toContain(`href="${url}"`)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('is forgotten when the editor loses focus, but not to the paste menu', () => {
|
|
138
|
+
const ed = createEditor('<p></p>')
|
|
139
|
+
paste(ed, url)
|
|
140
|
+
|
|
141
|
+
const menu = document.body.appendChild(document.createElement('div'))
|
|
142
|
+
menu.className = PASTE_CARD_MENU_CLASS
|
|
143
|
+
const button = menu.appendChild(document.createElement('button'))
|
|
144
|
+
|
|
145
|
+
ed.view.dispatch(ed.state.tr.setMeta('blur', { event: new FocusEvent('blur', { relatedTarget: button }) }))
|
|
146
|
+
expect(pastedLink(ed.state)?.url).toBe(url)
|
|
147
|
+
|
|
148
|
+
ed.view.dispatch(ed.state.tr.setMeta('blur', { event: new FocusEvent('blur') }))
|
|
149
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
150
|
+
menu.remove()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('follows its paragraph through changes before it', () => {
|
|
154
|
+
const ed = createEditor('<p>Before</p><p></p>')
|
|
155
|
+
ed.commands.setTextSelection(9)
|
|
156
|
+
paste(ed, url)
|
|
157
|
+
|
|
158
|
+
// An appended transaction (e.g. what the paste brings along) is not the author's change: the link stays
|
|
159
|
+
ed.view.dispatch(ed.state.tr.insertText('Very ', 1).setMeta('appendedTransaction', ed.state.tr))
|
|
160
|
+
|
|
161
|
+
expect(pastedLink(ed.state)?.paragraphFrom).toBe(13)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('replacePastedLink', () => {
|
|
166
|
+
it('replaces the paragraph of the pasted link with its card', () => {
|
|
167
|
+
const ed = createEditor('<p>Before</p><p></p><p>After</p>')
|
|
168
|
+
ed.commands.setTextSelection(9)
|
|
169
|
+
paste(ed, url)
|
|
170
|
+
|
|
171
|
+
expect(replacePastedLink(ed, card)).toBe(true)
|
|
172
|
+
|
|
173
|
+
expect(ed.state.doc.childCount).toBe(3)
|
|
174
|
+
expect(ed.state.doc.child(1).type.name).toBe('linkCard')
|
|
175
|
+
expect(ed.state.doc.child(1).attrs).toEqual(card)
|
|
176
|
+
expect(ed.state.doc.child(2).textContent).toBe('After')
|
|
177
|
+
expect(pastedLink(ed.state)).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('does nothing once the link is gone or for another address', () => {
|
|
181
|
+
const ed = createEditor('<p></p>')
|
|
182
|
+
paste(ed, url)
|
|
183
|
+
|
|
184
|
+
expect(replacePastedLink(ed, { ...card, href: 'https://other.example.com' })).toBe(false)
|
|
185
|
+
|
|
186
|
+
ed.commands.insertContent('x')
|
|
187
|
+
expect(replacePastedLink(ed, card)).toBe(false)
|
|
188
|
+
expect(ed.state.doc.child(0).type.name).toBe('paragraph')
|
|
189
|
+
})
|
|
190
|
+
})
|