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.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +37 -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 +210 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +117 -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 +10 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. package/src/utils/validateHref.ts +47 -7
@@ -0,0 +1,133 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { App, createApp, h, nextTick, provide, ref, shallowRef } from 'vue'
3
+ import { createVuetify } from 'vuetify'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import { Document } from '@tiptap/extension-document'
6
+ import { Paragraph } from '@tiptap/extension-paragraph'
7
+ import { Text } from '@tiptap/extension-text'
8
+ import { Link } from '@tiptap/extension-link'
9
+ import { LinkCard } from '@tiptapify/extensions/components/media/linkCard'
10
+ import PasteCardMenu from '@tiptapify/extensions/components/media/linkCard/PasteCardMenu.vue'
11
+ import { LinkCardPaste, pastedLink } from '@tiptapify/extensions/components/media/linkCard/pasteCard'
12
+ import { TIPTAPIFY_LINK_PREVIEW_KEY } from '@tiptapify/composables/useTiptapifyLinkPreview'
13
+
14
+ // jsdom has no `ResizeObserver`; the spinner of the loading button reads it
15
+ if (!('ResizeObserver' in globalThis)) {
16
+ ;(globalThis as Record<string, unknown>).ResizeObserver = class {
17
+ observe() {}
18
+ unobserve() {}
19
+ disconnect() {}
20
+ }
21
+ }
22
+
23
+ // Nor the layout of a range, which the menu position reads
24
+ if (!Range.prototype.getClientRects) {
25
+ Range.prototype.getClientRects = () => [] as unknown as DOMRectList
26
+ Range.prototype.getBoundingClientRect = () => new DOMRect()
27
+ }
28
+
29
+ const url = 'https://news.example.com/a'
30
+
31
+ let app: App | null = null
32
+ let editor: Editor | null = null
33
+
34
+ async function flush() {
35
+ for (let i = 0; i < 5; i++) {
36
+ await nextTick()
37
+ }
38
+ }
39
+
40
+ async function mountMenu(available: boolean): Promise<Editor> {
41
+ const scroll = document.body.appendChild(document.createElement('div'))
42
+ editor = new Editor({
43
+ element: scroll.appendChild(document.createElement('div')),
44
+ content: '<p>Before</p><p></p>',
45
+ extensions: [Document, Paragraph, Text, Link.configure({ openOnClick: false }), LinkCard, LinkCardPaste],
46
+ })
47
+
48
+ app = createApp({
49
+ setup() {
50
+ provide('tiptapifyEditor', shallowRef(editor))
51
+ provide('tiptapifyScrollContainer', ref(scroll))
52
+ provide('tiptapifyI18n', { t: (key: string) => key })
53
+ provide(TIPTAPIFY_LINK_PREVIEW_KEY, {
54
+ options: ref({ endpoint: 'https://cms.example.com/preview' }),
55
+ available: ref(available),
56
+ })
57
+
58
+ return () => h(PasteCardMenu)
59
+ },
60
+ })
61
+ app.use(createVuetify())
62
+ app.mount(document.body.appendChild(document.createElement('div')))
63
+ await flush()
64
+
65
+ editor.commands.setTextSelection(9)
66
+ editor.view.pasteText(url, new Event('paste') as ClipboardEvent)
67
+ await flush()
68
+
69
+ return editor
70
+ }
71
+
72
+ function menu(): HTMLElement | null {
73
+ return document.querySelector<HTMLElement>('.tiptapify-link-card-paste')
74
+ }
75
+
76
+ function buttonWithText(text: string): HTMLButtonElement {
77
+ return [...menu()!.querySelectorAll('button')].find(button => button.textContent?.includes(text))!
78
+ }
79
+
80
+ function respond(body: unknown, status = 200) {
81
+ return vi.fn(async () => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }))
82
+ }
83
+
84
+ afterEach(() => {
85
+ app?.unmount()
86
+ editor?.destroy()
87
+ app = null
88
+ editor = null
89
+ document.body.innerHTML = ''
90
+ vi.unstubAllGlobals()
91
+ })
92
+
93
+ describe('paste card menu', () => {
94
+ it('is not offered without the link preview endpoint', async () => {
95
+ const ed = await mountMenu(false)
96
+
97
+ expect(pastedLink(ed.state)?.url).toBe(url)
98
+ expect(menu()).toBeNull()
99
+ })
100
+
101
+ it('keeps the link on Link', async () => {
102
+ const ed = await mountMenu(true)
103
+
104
+ buttonWithText('dialog.linkCard.display_link').click()
105
+ await flush()
106
+
107
+ expect(menu()).toBeNull()
108
+ expect(ed.getHTML()).toContain(`href="${url}"`)
109
+ })
110
+
111
+ it('replaces the pasted link with its card on Card', async () => {
112
+ vi.stubGlobal('fetch', respond({ title: 'Talks', siteName: 'News' }))
113
+ const ed = await mountMenu(true)
114
+
115
+ buttonWithText('dialog.linkCard.display_card').click()
116
+
117
+ await vi.waitFor(() => expect(ed.state.doc.child(1).type.name).toBe('linkCard'))
118
+ expect(ed.state.doc.child(1).attrs).toMatchObject({ href: url, title: 'Talks', siteName: 'News', newTab: true })
119
+ await flush()
120
+ expect(menu()).toBeNull()
121
+ })
122
+
123
+ it('keeps the link with a short error when the page has no preview', async () => {
124
+ vi.stubGlobal('fetch', respond({ error: 'Not found' }, 404))
125
+ const ed = await mountMenu(true)
126
+
127
+ buttonWithText('dialog.linkCard.display_card').click()
128
+
129
+ await vi.waitFor(() => expect(menu()!.textContent).toContain('dialog.linkCard.paste_error'))
130
+ expect(ed.state.doc.child(1).type.name).toBe('paragraph')
131
+ expect(ed.getHTML()).toContain(`href="${url}"`)
132
+ })
133
+ })
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ import * as mdi from '@mdi/js'
3
+ import { posToDOMRect } from '@tiptap/core'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import { BubbleMenu } from '@tiptap/vue-3/menus'
6
+ import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
7
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
9
+ import { useTiptapifyLinkPreview } from '@tiptapify/composables/useTiptapifyLinkPreview'
10
+ import { useTiptapifyUpload } from '@tiptapify/composables/useTiptapifyUpload'
11
+ import { makeLinkCard } from '@tiptapify/extensions/components/media/linkCard/makeLinkCard'
12
+ import {
13
+ dismissPastedLink,
14
+ PASTE_CARD_MENU_CLASS,
15
+ pastedLink,
16
+ replacePastedLink,
17
+ } from '@tiptapify/extensions/components/media/linkCard/pasteCard'
18
+ import { inject, onBeforeUnmount, Ref, ref, watch } from 'vue'
19
+ import { ComposerTranslation } from 'vue-i18n'
20
+
21
+ // The choice after a link is pasted alone into an empty paragraph: keep the link, or show it as a card
22
+ const editor = inject('tiptapifyEditor') as Ref<Editor>
23
+ const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
24
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
25
+
26
+ const { shape } = useTiptapifyConfig()
27
+ const { available, fetchPreview } = useTiptapifyLinkPreview()
28
+ const { upload } = useTiptapifyUpload()
29
+
30
+ // The address of the pasted link the menu is for, kept in step with the editor state
31
+ const url = ref<string | null>(null)
32
+ const loading = ref(false)
33
+ const error = ref('')
34
+ let request: AbortController | null = null
35
+
36
+ function sync() {
37
+ url.value = editor.value ? pastedLink(editor.value.state)?.url ?? null : null
38
+ }
39
+
40
+ watch(editor, (current, previous) => {
41
+ previous?.off('transaction', sync)
42
+ current?.on('transaction', sync)
43
+ sync()
44
+ }, { immediate: true })
45
+
46
+ onBeforeUnmount(() => {
47
+ editor.value?.off('transaction', sync)
48
+ request?.abort()
49
+ })
50
+
51
+ // A new link or none: what the menu showed for the previous one is gone, a pending card too
52
+ watch(url, () => {
53
+ request?.abort()
54
+ request = null
55
+ loading.value = false
56
+ error.value = ''
57
+ })
58
+
59
+ function shouldShow({ state }: { state: Editor['state'] }): boolean {
60
+ return available.value && !!pastedLink(state)
61
+ }
62
+
63
+ function linkEnd() {
64
+ const link = editor.value ? pastedLink(editor.value.state) : null
65
+ if (!link) {
66
+ return null
67
+ }
68
+
69
+ const rect = posToDOMRect(editor.value.view, link.to, link.to)
70
+
71
+ return { getBoundingClientRect: () => rect, getClientRects: () => [rect] }
72
+ }
73
+
74
+ function keepLink() {
75
+ dismissPastedLink(editor.value)
76
+ }
77
+
78
+ async function showCard() {
79
+ const link = pastedLink(editor.value.state)
80
+ if (!link || loading.value) {
81
+ return
82
+ }
83
+
84
+ const current = new AbortController()
85
+ request = current
86
+ loading.value = true
87
+ error.value = ''
88
+
89
+ try {
90
+ const attrs = await makeLinkCard(link.url, { fetchPreview, upload: upload.value, signal: current.signal })
91
+ if (!current.signal.aborted) {
92
+ replacePastedLink(editor.value, attrs)
93
+ }
94
+ } catch {
95
+ if (!current.signal.aborted) {
96
+ error.value = t('dialog.linkCard.paste_error')
97
+ }
98
+ } finally {
99
+ if (request === current) {
100
+ request = null
101
+ loading.value = false
102
+ }
103
+ }
104
+ }
105
+ </script>
106
+
107
+ <template>
108
+ <BubbleMenu
109
+ v-if="editor && scrollTarget"
110
+ :editor="editor"
111
+ plugin-key="tiptapifyLinkCardPasteMenu"
112
+ :options="{ placement: 'right', offset: 8, flip: { fallbackPlacements: ['bottom-end', 'top-end'] }, scrollTarget }"
113
+ :should-show="shouldShow"
114
+ :get-referenced-virtual-element="linkEnd"
115
+ >
116
+ <div
117
+ v-if="available && url"
118
+ :class="[PASTE_CARD_MENU_CLASS, { 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }]"
119
+ @mousedown.prevent
120
+ >
121
+ <VBtn size="small" variant="text" color="" :disabled="loading" @click="keepLink">
122
+ {{ t('dialog.linkCard.display_link') }}
123
+ </VBtn>
124
+ <VBtn size="small" variant="text" color="" :loading="loading" @click="showCard">
125
+ {{ t('dialog.linkCard.display_card') }}
126
+ </VBtn>
127
+
128
+ <span v-if="error" class="tiptapify-link-card-paste__error text-caption text-error">{{ error }}</span>
129
+
130
+ <div class="tiptapify-link-card-paste__divider" />
131
+
132
+ <VBtn size="28" variant="text" color="" @click="keepLink">
133
+ <Tooltip :label="t('dialog.linkCard.paste_dismiss')" />
134
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiClose}`" />
135
+ </VBtn>
136
+ </div>
137
+ </BubbleMenu>
138
+ </template>
139
+
140
+ <style scoped lang="scss">
141
+ /* The look of the image menu: the bubble tokens, the classic shape's base as fallback */
142
+ .tiptapify-link-card-paste {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: var(--tf-bubble-gap, 2px);
146
+ padding: var(--tf-bubble-padding, 4px);
147
+ border: var(--tf-bubble-border, none);
148
+ border-radius: var(--tf-bubble-radius, 6px);
149
+ background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
150
+ box-shadow: var(--tf-bubble-shadow, 0 4px 14px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08));
151
+
152
+ .v-btn {
153
+ letter-spacing: normal;
154
+ text-transform: none;
155
+ }
156
+ }
157
+
158
+ .tiptapify-link-card-paste__error {
159
+ padding: 0 6px;
160
+ }
161
+
162
+ .tiptapify-link-card-paste__divider {
163
+ width: 1px;
164
+ height: 20px;
165
+ margin: 0 4px;
166
+ background: rgba(var(--v-border-color), var(--v-border-opacity));
167
+ }
168
+ </style>
@@ -0,0 +1,146 @@
1
+ import { afterEach, describe, expect, it, vi } 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 Image from '@tiptap/extension-image'
8
+ import { TiptapifyLink } from '@tiptapify/extensions/components/media/link'
9
+ import { applyLinkCard, LinkCard, selectedLinkCard } from '@tiptapify/extensions/components/media/linkCard'
10
+
11
+ let editor: Editor | null = null
12
+
13
+ function createEditor(content: string) {
14
+ editor = new Editor({
15
+ element: document.createElement('div'),
16
+ content,
17
+ extensions: [Document, Paragraph, Text, Link, TiptapifyLink, Image, LinkCard],
18
+ })
19
+
20
+ return editor
21
+ }
22
+
23
+ const card = {
24
+ href: 'https://news.example.com/a',
25
+ title: 'Talks enter a new stage',
26
+ description: 'The president named the terms',
27
+ image: 'https://cdn.example.com/og.jpg',
28
+ siteName: 'News',
29
+ layout: 'compact',
30
+ newTab: true,
31
+ }
32
+
33
+ const cardHtml = '<a class="tiptapify-link-card" data-link-card="compact" href="https://news.example.com/a" target="_blank" rel="noopener noreferrer">'
34
+ + '<img class="tiptapify-link-card__image" src="https://cdn.example.com/og.jpg" alt="">'
35
+ + '<span class="tiptapify-link-card__body">'
36
+ + '<span class="tiptapify-link-card__site">News</span>'
37
+ + '<span class="tiptapify-link-card__title">Talks enter a new stage</span>'
38
+ + '<span class="tiptapify-link-card__description">The president named the terms</span>'
39
+ + '</span></a>'
40
+
41
+ describe('link card node', () => {
42
+ afterEach(() => {
43
+ editor?.destroy()
44
+ editor = null
45
+ })
46
+
47
+ it('is saved as one link with the image and the texts, so the whole card is clickable on a site', () => {
48
+ createEditor('')
49
+ editor!.commands.setContent({ type: 'doc', content: [{ type: 'linkCard', attrs: card }] })
50
+
51
+ expect(editor!.getHTML()).toBe(cardHtml)
52
+ })
53
+
54
+ it('loads back from the saved HTML as a card, not as a link and an image', () => {
55
+ createEditor(`<p>Before</p>${cardHtml}`)
56
+
57
+ const node = editor!.state.doc.child(1)
58
+ expect(node.type.name).toBe('linkCard')
59
+ expect(node.attrs).toEqual(card)
60
+ })
61
+
62
+ it('leaves out what it does not have: no image for the text layout, no empty texts, the same tab', () => {
63
+ createEditor('')
64
+ editor!.commands.setContent({
65
+ type: 'doc',
66
+ content: [{ type: 'linkCard', attrs: { href: 'https://a.example', title: 'A', image: 'https://a.example/i.png', layout: 'text', newTab: false } }],
67
+ })
68
+
69
+ expect(editor!.getHTML()).toBe(
70
+ '<a class="tiptapify-link-card" data-link-card="text" href="https://a.example">'
71
+ + '<span class="tiptapify-link-card__body"><span class="tiptapify-link-card__title">A</span></span></a>',
72
+ )
73
+ })
74
+
75
+ it('updates the selected card in place and inserts a new one otherwise', () => {
76
+ createEditor(`<p>Text</p>${cardHtml}`)
77
+
78
+ editor!.commands.setNodeSelection(6)
79
+ const edited = selectedLinkCard(editor!)
80
+ expect(edited?.title).toBe(card.title)
81
+
82
+ applyLinkCard(editor!, { ...card, layout: 'large' }, true)
83
+ expect(editor!.state.doc.childCount).toBe(2)
84
+ expect(editor!.state.doc.child(1).attrs.layout).toBe('large')
85
+
86
+ editor!.commands.setTextSelection(3)
87
+ applyLinkCard(editor!, { ...card, title: 'Second' }, false)
88
+ expect(editor!.state.doc.toJSON().content.filter((node: { type: string }) => node.type === 'linkCard')).toHaveLength(2)
89
+ })
90
+
91
+ it('opens the link dialog with the card on double click', () => {
92
+ createEditor(cardHtml)
93
+ const listener = vi.fn()
94
+ window.addEventListener('tiptapify-show-link', listener)
95
+
96
+ const { view } = editor!
97
+ const handled = view.someProp('handleDoubleClickOn', handle => handle(view, 0, view.state.doc.nodeAt(0)!, 0, new MouseEvent('dblclick'), true))
98
+
99
+ window.removeEventListener('tiptapify-show-link', listener)
100
+ expect(handled).toBe(true)
101
+ expect(listener.mock.calls[0][0].detail.card).toMatchObject({ href: card.href, title: card.title })
102
+ })
103
+
104
+ it('opens the link dialog with a card of the given address by showLinkCard', () => {
105
+ createEditor('<p></p>')
106
+ const listener = vi.fn()
107
+ window.addEventListener('tiptapify-show-link', listener)
108
+
109
+ editor!.commands.showLinkCard({ href: card.href, newTab: true })
110
+
111
+ window.removeEventListener('tiptapify-show-link', listener)
112
+ expect(listener.mock.calls[0][0].detail).toMatchObject({ card: null, draft: { href: card.href, newTab: true } })
113
+ })
114
+
115
+ it('does not open its link on a click while editing, so a click selects it and a double click edits it', () => {
116
+ createEditor(cardHtml)
117
+ const link = editor!.view.dom.querySelector('a[data-link-card]')!
118
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true })
119
+
120
+ link.dispatchEvent(click)
121
+
122
+ expect(click.defaultPrevented).toBe(true)
123
+ })
124
+
125
+ it('opens its link in a read-only editor', () => {
126
+ createEditor(cardHtml)
127
+ editor!.setEditable(false)
128
+ const click = new MouseEvent('click', { bubbles: true, cancelable: true })
129
+
130
+ editor!.view.dom.querySelector('a[data-link-card]')!.dispatchEvent(click)
131
+
132
+ expect(click.defaultPrevented).toBe(false)
133
+ })
134
+
135
+ it('is selected by a click, before the link mark selects its text (enableClickSelection)', () => {
136
+ createEditor(`<p>Text</p>${cardHtml}`)
137
+ const { view } = editor!
138
+ const pos = 6
139
+
140
+ const handled = view.someProp('handleClickOn', handle => handle(view, pos + 1, view.state.doc.nodeAt(pos)!, pos, new MouseEvent('click'), true))
141
+
142
+ expect(handled).toBe(true)
143
+ expect(editor!.state.selection.from).toBe(pos)
144
+ expect(selectedLinkCard(editor!)?.href).toBe(card.href)
145
+ })
146
+ })
@@ -0,0 +1,177 @@
1
+ import { Editor, Node } from '@tiptap/core'
2
+ import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'
3
+
4
+ export type LinkCardLayout = 'compact' | 'large' | 'text'
5
+
6
+ export type LinkCardAttrs = {
7
+ href: string,
8
+ title: string | null,
9
+ description: string | null,
10
+ image: string | null,
11
+ siteName: string | null,
12
+ layout: LinkCardLayout,
13
+ newTab: boolean,
14
+ }
15
+
16
+ // An address the link dialog opens with to make a card of
17
+ export type LinkCardDraft = { href: string, newTab?: boolean }
18
+
19
+ declare module '@tiptap/core' {
20
+ interface Commands<ReturnType> {
21
+ linkCard: {
22
+ setLinkCard: (attrs: Partial<LinkCardAttrs> & { href: string }) => ReturnType
23
+ showLinkCard: (draft?: LinkCardDraft) => ReturnType
24
+ }
25
+ }
26
+ }
27
+
28
+ const name = 'linkCard'
29
+ const LAYOUTS: LinkCardLayout[] = ['compact', 'large', 'text']
30
+ const CLASS = 'tiptapify-link-card'
31
+
32
+ function childText(element: HTMLElement, part: string): string | null {
33
+ return element.querySelector(`.${CLASS}__${part}`)?.textContent?.trim() || null
34
+ }
35
+
36
+ function span(part: string, text: string | null): unknown[] {
37
+ return text ? [['span', { class: `${CLASS}__${part}` }, text]] : []
38
+ }
39
+
40
+ /**
41
+ * A link shown as a card with the page's OpenGraph data (image, site, title, description). Its own node, not
42
+ * an image with a link, so the link dialog edits it; saved as one `<a>`, so the whole card is a link.
43
+ */
44
+ export const LinkCard = Node.create({
45
+ name,
46
+
47
+ group: 'block',
48
+
49
+ atom: true,
50
+
51
+ draggable: true,
52
+
53
+ addAttributes() {
54
+ return {
55
+ href: { default: '', rendered: false, parseHTML: element => element.getAttribute('href') ?? '' },
56
+ title: { default: null, rendered: false, parseHTML: element => childText(element, 'title') },
57
+ description: { default: null, rendered: false, parseHTML: element => childText(element, 'description') },
58
+ image: {
59
+ default: null,
60
+ rendered: false,
61
+ parseHTML: element => element.querySelector(`.${CLASS}__image`)?.getAttribute('src') || null,
62
+ },
63
+ siteName: { default: null, rendered: false, parseHTML: element => childText(element, 'site') },
64
+ layout: {
65
+ default: 'compact',
66
+ rendered: false,
67
+ parseHTML: (element) => {
68
+ const layout = element.getAttribute('data-link-card') as LinkCardLayout
69
+
70
+ return LAYOUTS.includes(layout) ? layout : 'compact'
71
+ },
72
+ },
73
+ newTab: { default: true, rendered: false, parseHTML: element => element.getAttribute('target') === '_blank' },
74
+ }
75
+ },
76
+
77
+ // Before the link mark (and so the image in it), which would read the card as a link and an image
78
+ parseHTML() {
79
+ return [{ tag: 'a[data-link-card]', priority: 1000 }]
80
+ },
81
+
82
+ renderHTML({ node }) {
83
+ const attrs = node.attrs as LinkCardAttrs
84
+ const image = attrs.image && attrs.layout !== 'text'
85
+ ? [['img', { class: `${CLASS}__image`, src: attrs.image, alt: '' }]]
86
+ : []
87
+
88
+ return [
89
+ 'a',
90
+ {
91
+ class: CLASS,
92
+ 'data-link-card': attrs.layout,
93
+ href: attrs.href,
94
+ ...(attrs.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
95
+ },
96
+ ...image,
97
+ [
98
+ 'span',
99
+ { class: `${CLASS}__body` },
100
+ ...span('site', attrs.siteName),
101
+ ...span('title', attrs.title),
102
+ ...span('description', attrs.description),
103
+ ],
104
+ ] as never
105
+ },
106
+
107
+ addCommands() {
108
+ return {
109
+ setLinkCard: attrs => ({ commands }) => commands.insertContent({ type: name, attrs }),
110
+
111
+ // The link dialog shows cards too: the selected card, else a card of the draft address (empty: a new one)
112
+ showLinkCard: draft => ({ commands }) => commands.showLink(draft ?? { href: '' }),
113
+ }
114
+ },
115
+
116
+ addProseMirrorPlugins() {
117
+ const { editor } = this
118
+
119
+ return [
120
+ new Plugin({
121
+ key: new PluginKey(name),
122
+ props: {
123
+ // While editing, the card's link does not open (its first click would, in a new tab)
124
+ handleDOMEvents: {
125
+ click: (view, event) => {
126
+ if (view.editable && (event.target as Element | null)?.closest?.('a[data-link-card]')) {
127
+ event.preventDefault()
128
+ }
129
+
130
+ return false
131
+ },
132
+ },
133
+ // A click selects the card. ProseMirror asks handleClickOn before handleClick, where the link mark
134
+ // (enableClickSelection) would put a text selection on the card's <a>
135
+ handleClickOn: (view, _pos, node, nodePos, _event, direct) => {
136
+ if (!direct || node.type.name !== name || !view.editable) {
137
+ return false
138
+ }
139
+
140
+ view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)))
141
+
142
+ return true
143
+ },
144
+ handleDoubleClickOn: (_view, _pos, node, nodePos) => {
145
+ if (node.type.name !== name) {
146
+ return false
147
+ }
148
+
149
+ editor.commands.setNodeSelection(nodePos)
150
+ editor.commands.showLink()
151
+
152
+ return true
153
+ },
154
+ },
155
+ }),
156
+ ]
157
+ },
158
+ })
159
+
160
+ export function selectedLinkCard(editor: Editor): LinkCardAttrs | null {
161
+ const { selection } = editor.state
162
+
163
+ return selection instanceof NodeSelection && selection.node.type.name === name
164
+ ? { ...selection.node.attrs } as LinkCardAttrs
165
+ : null
166
+ }
167
+
168
+ /**
169
+ * Updates the selected card being edited, or inserts a card in place of the selection (a link made a card).
170
+ */
171
+ export function applyLinkCard(editor: Editor, attrs: LinkCardAttrs, edited: boolean): boolean {
172
+ if (edited) {
173
+ return editor.chain().focus().updateAttributes(name, attrs).run()
174
+ }
175
+
176
+ return editor.chain().focus().insertContent({ type: name, attrs }).run()
177
+ }