tiptapify 0.3.4 → 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 +110820 -65669
  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 +72 -2
  137. package/src/utils/validateHref.ts +57 -13
@@ -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('&', '&amp;')}</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
+ })