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
@@ -1,135 +1,663 @@
1
1
  <script setup lang="ts">
2
2
 
3
+ import * as mdi from '@mdi/js'
3
4
  import { Editor } from '@tiptap/vue-3'
5
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
4
6
  import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
7
+ import { useTiptapifyLinkPreview } from '@tiptapify/composables/useTiptapifyLinkPreview'
8
+ import { useTiptapifyUpload } from '@tiptapify/composables/useTiptapifyUpload'
9
+ import { applyLinkCard, LinkCardAttrs, LinkCardDraft, LinkCardLayout } from '@tiptapify/extensions/components/media/linkCard'
10
+ import {
11
+ isLinkCardAddress,
12
+ LINK_CARD_LAYOUTS,
13
+ linkCardAddressProblem,
14
+ linkCardToLink,
15
+ } from '@tiptapify/extensions/components/media/linkCard/linkCardDialog'
16
+ import { makeLinkCard } from '@tiptapify/extensions/components/media/linkCard/makeLinkCard'
17
+ import {
18
+ applyLink,
19
+ formRel,
20
+ hasAdvancedValues,
21
+ LinkContext,
22
+ LINK_PROTOCOLS,
23
+ linkToCard,
24
+ missingProtocol,
25
+ parseRel,
26
+ REL_SUGGESTIONS,
27
+ withProtocol,
28
+ removeLink,
29
+ } from '@tiptapify/extensions/components/media/link/linkDialog'
5
30
  import { isValidHref } from '@tiptapify/utils/validateHref'
6
31
 
7
- import { computed, inject, onMounted, onUnmounted, Ref, ref, useTemplateRef, watch } from 'vue'
32
+ import { computed, inject, onMounted, onUnmounted, reactive, Ref, ref, useTemplateRef, watch } from 'vue'
8
33
 
9
34
  import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
10
35
  import { ComposerTranslation } from 'vue-i18n'
11
36
 
12
- const { variantBtn, variantField } = useTiptapifyConfig()
37
+ // The page data of a card is asked once the author stops typing the address
38
+ const FETCH_DELAY = 400
39
+
40
+ const { variantBtn } = useTiptapifyConfig()
41
+ const { available, fetchPreview, recheck } = useTiptapifyLinkPreview()
42
+ const { upload, uploadFile } = useTiptapifyUpload()
13
43
 
14
44
  const editor = inject('tiptapifyEditor') as Ref<Editor>
15
45
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
16
46
 
17
- const generateLinkAttrs = () => ({
47
+ const relDelimiters = [' ', ',']
48
+
49
+ const generateForm = () => ({
18
50
  href: '',
19
- target: targetAttrs.value[0],
51
+ text: '',
52
+ newTab: false,
53
+ rel: [] as string[],
20
54
  cssClass: '',
21
- rel: []
55
+ title: '',
56
+ description: '',
57
+ siteName: '',
58
+ image: '',
59
+ layout: 'compact' as LinkCardLayout,
22
60
  })
23
61
 
24
- const relAttrs = ['alternate', 'author', 'bookmark', 'external', 'help', 'license', 'me', 'next', 'nofollow', 'noopener', 'noreferrer', 'opener', 'prev', 'privacy-policy', 'search', 'tag', 'terms-of-service']
62
+ const generateContext = (): LinkContext => ({ active: false, attrs: {}, text: '' })
25
63
 
26
- const targetAttrs = computed(() => [
27
- { value: '_blank', title: t('dialog.link.target_blank') },
28
- { value: '_self', title: t('dialog.link.target_self') }
29
- ])
64
+ const form = ref(generateForm())
65
+ const context = ref<LinkContext>(generateContext())
66
+ // A selected card is being edited (its texts may be the author's own: no fetch on open)
67
+ const cardEdit = ref(false)
68
+ const advancedOpen = ref(false)
69
+ const hrefError = ref('')
70
+ const fetchError = ref('')
71
+ const display = ref<'link' | 'card'>('link')
72
+ const loading = ref(false)
73
+ const image = reactive({ uploading: false, error: '' })
30
74
 
31
- const attrs = ref(generateLinkAttrs())
32
- const hrefInvalid = ref(false)
75
+ // The address of the page data asked last: the same address is not asked again
76
+ let fetchedHref = ''
77
+ let fetchTimer: ReturnType<typeof setTimeout> | null = null
78
+ let fetchRequest: AbortController | null = null
79
+ let imageUpload: AbortController | null = null
33
80
 
34
81
  const dialog = useTemplateRef('dialog')
82
+ const imageInput = useTemplateRef<HTMLInputElement>('imageInput')
83
+
84
+ const isEdit = computed(() => context.value.active || cardEdit.value)
85
+ const isDisabled = computed(() => !form.value.href.trim() || loading.value || image.uploading)
86
+ // A new card only with the link preview endpoint up; a card already in the document stays editable by hand
87
+ const canShowCard = computed(() => available.value || cardEdit.value)
88
+ const isCard = computed(() => canShowCard.value && display.value === 'card')
89
+ const canRefresh = computed(() => available.value && !loading.value && isLinkCardAddress(form.value.href))
35
90
 
36
- const isDisabled = computed(() => {
37
- const { href } = attrs.value
38
- return !href
91
+ const title = computed(() => {
92
+ if (isCard.value) {
93
+ return t(isEdit.value ? 'dialog.linkCard.title_edit' : 'dialog.linkCard.title_insert')
94
+ }
95
+
96
+ return t(isEdit.value ? 'dialog.link.title_edit' : 'dialog.link.title_insert')
39
97
  })
40
98
 
99
+ const layouts = computed(() => LINK_CARD_LAYOUTS.map(value => ({ value, label: t(`dialog.linkCard.layout_${value}`) })))
100
+
101
+ const relHint = computed(() => {
102
+ const hint = t('dialog.link.rel_hint')
103
+
104
+ return form.value.newTab ? `${hint} ${t('dialog.link.rel_new_tab_hint')}` : hint
105
+ })
106
+
107
+ function updateRel(value: string[]) {
108
+ form.value.rel = parseRel(value)
109
+ }
110
+
111
+ // An address without a protocol: the author picks one of the offered protocols, the dialog never picks it
112
+ const hrefMissingProtocol = computed(() => missingProtocol(form.value.href))
113
+
114
+ const hrefField = useTemplateRef<{ focus: () => void }>('hrefField')
115
+
116
+ // Back to the address, so Enter inserts the link right away
117
+ function addProtocol(protocol: string) {
118
+ form.value.href = withProtocol(form.value.href, protocol)
119
+ hrefField.value?.focus()
120
+ }
121
+
122
+ function errorMessage(error: unknown): string {
123
+ return error instanceof Error ? error.message : String(error)
124
+ }
125
+
126
+ function cancelFetch() {
127
+ if (fetchTimer) {
128
+ clearTimeout(fetchTimer)
129
+ fetchTimer = null
130
+ }
131
+
132
+ // An address whose request was canceled is asked again when the dialog shows a card of it again
133
+ if (fetchRequest) {
134
+ fetchRequest.abort()
135
+ fetchRequest = null
136
+ fetchedHref = ''
137
+ }
138
+ loading.value = false
139
+ }
140
+
141
+ // The page data into the card fields; false when it failed or another request replaced it
142
+ async function fetchCard(): Promise<boolean> {
143
+ cancelFetch()
144
+
145
+ const href = form.value.href.trim()
146
+
147
+ if (!available.value || !isLinkCardAddress(href)) {
148
+ return false
149
+ }
150
+
151
+ const controller = new AbortController()
152
+ fetchRequest = controller
153
+ fetchedHref = href
154
+ loading.value = true
155
+ fetchError.value = ''
156
+
157
+ // A canceled or replaced request (another controller) leaves the form alone when it settles
158
+ try {
159
+ const card = await makeLinkCard(href, { fetchPreview, upload: upload.value, signal: controller.signal })
160
+
161
+ if (fetchRequest !== controller) {
162
+ return false
163
+ }
164
+
165
+ Object.assign(form.value, {
166
+ title: card.title ?? '',
167
+ description: card.description ?? '',
168
+ siteName: card.siteName ?? '',
169
+ image: card.image ?? '',
170
+ })
171
+
172
+ return true
173
+ } catch (error) {
174
+ if (fetchRequest === controller) {
175
+ fetchError.value = t('dialog.linkCard.fetch_failed', { error: errorMessage(error) })
176
+ }
177
+
178
+ return false
179
+ } finally {
180
+ if (fetchRequest === controller) {
181
+ fetchRequest = null
182
+ loading.value = false
183
+ }
184
+ }
185
+ }
186
+
187
+ // A new card asks the data of its address; an edited card's data is asked again only by Refresh
188
+ function wantsFetch(): boolean {
189
+ return isCard.value && !cardEdit.value && available.value && isLinkCardAddress(form.value.href)
190
+ && form.value.href.trim() !== fetchedHref
191
+ }
192
+
193
+ function scheduleFetch() {
194
+ cancelFetch()
195
+
196
+ if (wantsFetch()) {
197
+ fetchTimer = setTimeout(() => {
198
+ fetchTimer = null
199
+ void fetchCard()
200
+ }, FETCH_DELAY)
201
+ }
202
+ }
203
+
204
+ function cancelImageUpload() {
205
+ imageUpload?.abort()
206
+ imageUpload = null
207
+ image.uploading = false
208
+ }
209
+
210
+ async function replaceImage(event: Event) {
211
+ const input = event.target as HTMLInputElement
212
+ const picked = input.files?.[0] ?? null
213
+
214
+ // Picking the same file again must fire `change` again
215
+ input.value = ''
216
+
217
+ if (!picked) {
218
+ return
219
+ }
220
+
221
+ cancelImageUpload()
222
+
223
+ const controller = new AbortController()
224
+ imageUpload = controller
225
+ image.uploading = true
226
+ image.error = ''
227
+
228
+ try {
229
+ const { url } = await uploadFile(picked, { kind: 'image', onProgress: () => {}, signal: controller.signal })
230
+
231
+ if (imageUpload === controller) {
232
+ form.value.image = url
233
+ }
234
+ } catch {
235
+ if (imageUpload === controller) {
236
+ image.error = t('dialog.linkCard.image_failed')
237
+ }
238
+ } finally {
239
+ if (imageUpload === controller) {
240
+ imageUpload = null
241
+ image.uploading = false
242
+ }
243
+ }
244
+ }
245
+
246
+ function removeImage() {
247
+ cancelImageUpload()
248
+ form.value.image = ''
249
+ image.error = ''
250
+ }
251
+
252
+ function cardAttrs(href: string): LinkCardAttrs {
253
+ const { title: cardTitle, description, image: src, siteName, layout, newTab } = form.value
254
+
255
+ return {
256
+ href,
257
+ title: cardTitle.trim() || null,
258
+ description: description.trim() || null,
259
+ image: src.trim() || null,
260
+ siteName: siteName.trim() || null,
261
+ layout,
262
+ newTab,
263
+ }
264
+ }
265
+
266
+ function addressError(href: string): string {
267
+ if (hrefMissingProtocol.value) {
268
+ return t('dialog.link.protocol_missing')
269
+ }
270
+
271
+ if (!isValidHref(href)) {
272
+ return t('dialog.link.href_invalid')
273
+ }
274
+
275
+ // A card is made of a page: only an http(s) address
276
+ const problem = isCard.value ? linkCardAddressProblem(href) : null
277
+
278
+ if (!problem) {
279
+ return ''
280
+ }
281
+
282
+ return problem === 'protocol' ? t('dialog.linkCard.protocol_missing') : t('dialog.linkCard.href_invalid')
283
+ }
284
+
41
285
  function apply() {
42
- let { href, target, rel, cssClass } = attrs.value
43
- const relStr = rel?.length ? rel.join(' ') : null
286
+ if (isDisabled.value) {
287
+ return
288
+ }
289
+
290
+ const href = form.value.href.trim()
291
+
292
+ hrefError.value = addressError(href)
293
+ if (hrefError.value) {
294
+ return
295
+ }
296
+
297
+ if (isCard.value) {
298
+ void applyCard(href)
299
+
300
+ return
301
+ }
44
302
 
45
- if (href) {
46
- editor.value.chain().focus().extendMarkRange('link').setLink({ href, target: target.value, rel: relStr, class: cssClass }).run()
303
+ // A card displayed as a link again: the link text, else the card's title, else the address
304
+ const applied = cardEdit.value
305
+ ? linkCardToLink(editor.value, {
306
+ href,
307
+ title: form.value.text.trim() || form.value.title,
308
+ newTab: form.value.newTab,
309
+ rel: form.value.rel,
310
+ cssClass: form.value.cssClass,
311
+ })
312
+ : applyLink(editor.value, { ...form.value, href }, context.value)
313
+
314
+ if (!applied) {
315
+ hrefError.value = t('dialog.link.apply_error')
316
+
317
+ return
47
318
  }
48
319
 
49
320
  close()
50
321
  }
51
322
 
52
- function clear() {
53
- editor.value.chain().focus().extendMarkRange('link').unsetLink().run()
323
+ // An address typed and submitted at once is asked first; a failed preview keeps the dialog open with the
324
+ // error, and the next submit inserts the fields filled by hand
325
+ async function applyCard(href: string) {
326
+ if (wantsFetch() && !await fetchCard()) {
327
+ return
328
+ }
329
+
330
+ const attrs = cardAttrs(href)
331
+ const applied = cardEdit.value
332
+ ? applyLinkCard(editor.value, attrs, true)
333
+ : linkToCard(editor.value, attrs, context.value)
334
+
335
+ if (!applied) {
336
+ hrefError.value = t('dialog.linkCard.apply_failed')
337
+
338
+ return
339
+ }
54
340
 
55
341
  close()
56
342
  }
57
343
 
58
- function close() {
59
- attrs.value = generateLinkAttrs()
344
+ function remove() {
345
+ if (cardEdit.value) {
346
+ editor.value.chain().focus().deleteSelection().run()
347
+ } else {
348
+ removeLink(editor.value)
349
+ }
60
350
 
351
+ close()
352
+ }
353
+
354
+ function close() {
61
355
  dialog.value?.close()
62
356
  }
63
357
 
64
- const showLink = (event: CustomEvent) => {
358
+ function reset() {
359
+ cancelFetch()
360
+ cancelImageUpload()
361
+
362
+ fetchedHref = ''
363
+ form.value = generateForm()
364
+ context.value = generateContext()
365
+ cardEdit.value = false
366
+ advancedOpen.value = false
367
+ hrefError.value = ''
368
+ fetchError.value = ''
369
+ image.error = ''
370
+ display.value = 'link'
371
+ }
372
+
373
+ type ShowLinkDetail = {
374
+ link?: Record<string, string | null>,
375
+ active?: boolean,
376
+ text?: string,
377
+ card?: LinkCardAttrs | null,
378
+ draft?: LinkCardDraft | null,
379
+ editorId: string,
380
+ }
381
+
382
+ function openCard(card: LinkCardAttrs) {
383
+ cardEdit.value = true
384
+ display.value = 'card'
385
+ Object.assign(form.value, {
386
+ href: card.href ?? '',
387
+ newTab: card.newTab !== false,
388
+ title: card.title ?? '',
389
+ description: card.description ?? '',
390
+ siteName: card.siteName ?? '',
391
+ image: card.image ?? '',
392
+ layout: LINK_CARD_LAYOUTS.includes(card.layout) ? card.layout : 'compact',
393
+ })
394
+ // The texts of a saved card stay as they are: they may be the author's own
395
+ fetchedHref = form.value.href.trim()
396
+ }
397
+
398
+ const showLink = (event: CustomEvent<ShowLinkDetail>) => {
65
399
  if (event.detail.editorId !== editor.value.instanceId) {
66
400
  return
67
401
  }
68
402
 
69
- attrs.value.href = event.detail.link?.href ?? ''
70
- attrs.value.target = targetAttrs.value.find(item => item.value === event.detail.link?.target) ?? targetAttrs.value[0]
71
- attrs.value.rel = event.detail.link?.rel?.split(' ')
72
- attrs.value.cssClass = event.detail.link?.class
403
+ reset()
404
+
405
+ const { card, draft } = event.detail
406
+ const link = event.detail.link ?? {}
407
+ const newTab = link.target === '_blank'
408
+
409
+ context.value = { active: !card && !!event.detail.active, attrs: card ? {} : link, text: card ? '' : event.detail.text ?? '' }
410
+ Object.assign(form.value, {
411
+ href: link.href ?? '',
412
+ text: context.value.text,
413
+ newTab,
414
+ rel: formRel(link.rel, newTab),
415
+ cssClass: link.class ?? '',
416
+ })
417
+
418
+ if (card) {
419
+ openCard(card)
420
+ } else if (draft) {
421
+ form.value.href = draft.href ?? ''
422
+ form.value.newTab = draft.newTab ?? false
423
+ display.value = 'card'
424
+ }
425
+
426
+ advancedOpen.value = hasAdvancedValues(form.value.rel, form.value.cssClass)
427
+ // An endpoint that failed may be up by now
428
+ void recheck()
73
429
 
74
430
  dialog.value?.open()
75
431
  }
76
432
 
433
+ watch(() => form.value.href, (href) => {
434
+ hrefError.value = ''
435
+
436
+ if (href.trim() === fetchedHref) {
437
+ return
438
+ }
439
+
440
+ fetchedHref = ''
441
+ fetchError.value = ''
442
+ scheduleFetch()
443
+ })
444
+
445
+ // Switched to a card (or the endpoint came up): asked at once, without the typing delay
446
+ watch(isCard, (card) => {
447
+ hrefError.value = ''
448
+
449
+ if (card && wantsFetch()) {
450
+ void fetchCard()
451
+ } else if (!card) {
452
+ cancelFetch()
453
+ }
454
+ })
455
+
77
456
  onMounted(() => {
78
457
  window.addEventListener('tiptapify-show-link', showLink as EventListener)
79
458
  })
80
459
 
81
460
  onUnmounted(() => {
82
461
  window.removeEventListener('tiptapify-show-link', showLink as EventListener)
83
- })
84
462
 
85
- watch(() => attrs.value.href, () => {
86
- hrefInvalid.value = (attrs.value.href || '') !== '' && !isValidHref(attrs.value.href)
463
+ cancelFetch()
464
+ cancelImageUpload()
87
465
  })
88
466
  </script>
89
467
 
90
468
  <template>
91
- <TiptapifyDialog ref="dialog" module="link">
469
+ <TiptapifyDialog
470
+ ref="dialog"
471
+ module="link"
472
+ :title="title"
473
+ :max-width="isCard ? 560 : 480"
474
+ @submit="apply"
475
+ @closed="reset"
476
+ >
92
477
  <template #content>
93
478
  <VCardText>
94
479
  <div class="tiptapify-dialog-grid">
480
+ <div v-if="canShowCard" class="tiptapify-dialog-col tiptapify-link-display">
481
+ <span class="text-body-2 text-medium-emphasis">{{ t('dialog.linkCard.display') }}</span>
482
+ <VBtnToggle
483
+ v-model="display"
484
+ mandatory
485
+ density="compact"
486
+ variant="outlined"
487
+ divided
488
+ >
489
+ <VBtn value="link" :prepend-icon="`mdiSvg:${mdi.mdiLinkVariant}`">
490
+ {{ t('dialog.linkCard.display_link') }}
491
+ </VBtn>
492
+ <VBtn value="card" :prepend-icon="`mdiSvg:${mdi.mdiCardTextOutline}`">
493
+ {{ t('dialog.linkCard.display_card') }}
494
+ </VBtn>
495
+ </VBtnToggle>
496
+ </div>
497
+
95
498
  <div class="tiptapify-dialog-col">
96
499
  <VTextField
97
- v-model="attrs.href"
500
+ ref="hrefField"
501
+ v-model="form.href"
98
502
  density="compact"
99
503
  variant="outlined"
100
504
  :label="t('dialog.link.href')"
101
- :error-messages="hrefInvalid ? t('dialog.link.href_error') : ''"
505
+ :error-messages="hrefError || fetchError"
506
+ :hint="loading ? t('dialog.linkCard.loading') : ''"
507
+ :loading="loading"
508
+ persistent-hint
509
+ hide-details="auto"
102
510
  autofocus
103
- />
511
+ >
512
+ <template v-if="isCard && available" #append-inner>
513
+ <VBtn
514
+ variant="text"
515
+ size="28"
516
+ icon
517
+ :disabled="!canRefresh"
518
+ :aria-label="t('dialog.linkCard.refresh')"
519
+ @click="fetchCard"
520
+ >
521
+ <VIcon size="18" :icon="`mdiSvg:${mdi.mdiRefresh}`" />
522
+ <Tooltip :label="t('dialog.linkCard.refresh')" />
523
+ </VBtn>
524
+ </template>
525
+ </VTextField>
526
+ <div v-if="hrefMissingProtocol" class="tiptapify-link-protocols">
527
+ <span v-if="!hrefError" class="text-caption text-medium-emphasis">{{ t('dialog.link.protocol_missing') }}</span>
528
+ <VChip
529
+ v-for="protocol in LINK_PROTOCOLS"
530
+ :key="protocol"
531
+ size="small"
532
+ label
533
+ variant="outlined"
534
+ @click="addProtocol(protocol)"
535
+ >
536
+ {{ protocol }}://
537
+ </VChip>
538
+ </div>
104
539
  </div>
105
540
 
106
- <div class="tiptapify-dialog-col tiptapify-dialog-col--4">
107
- <VSelect
108
- v-model="attrs.target"
109
- :items="targetAttrs"
110
- :label="t('dialog.link.target')"
111
- variant="outlined"
112
- return-object
541
+ <div v-if="!isCard" class="tiptapify-dialog-col">
542
+ <VTextField
543
+ v-model="form.text"
113
544
  density="compact"
545
+ variant="outlined"
546
+ :label="t('dialog.link.text')"
547
+ :hint="context.text ? '' : t('dialog.link.text_hint')"
548
+ persistent-hint
549
+ hide-details="auto"
114
550
  />
115
551
  </div>
116
552
 
117
- <div class="tiptapify-dialog-col tiptapify-dialog-col--8">
118
- <VTextField v-model="attrs.cssClass" density="compact" variant="outlined" :label="t('dialog.link.class')" />
553
+ <div v-if="isCard" class="tiptapify-dialog-col tiptapify-link-card-fields">
554
+ <div class="tiptapify-link-card-fields__media" role="group" :aria-label="t('dialog.linkCard.image')">
555
+ <input ref="imageInput" type="file" accept="image/*" hidden @change="replaceImage">
556
+ <div class="tiptapify-link-card-fields__image">
557
+ <VProgressCircular v-if="image.uploading || loading" indeterminate size="20" width="2" />
558
+ <img v-else-if="form.image" :src="form.image" alt="">
559
+ <VIcon v-else size="28" :icon="`mdiSvg:${mdi.mdiImageOutline}`" />
560
+ </div>
561
+ <div class="tiptapify-link-card-fields__image-actions">
562
+ <VBtn size="small" variant="text" :disabled="image.uploading" @click="imageInput?.click()">
563
+ {{ t(form.image ? 'dialog.linkCard.image_replace' : 'dialog.linkCard.image_choose') }}
564
+ </VBtn>
565
+ <VBtn size="small" variant="text" :disabled="!form.image && !image.uploading" @click="removeImage">
566
+ {{ t('dialog.linkCard.image_remove') }}
567
+ </VBtn>
568
+ </div>
569
+ <div v-if="image.error" class="text-error text-caption" role="alert">
570
+ {{ image.error }}
571
+ </div>
572
+ </div>
573
+
574
+ <div class="tiptapify-link-card-fields__texts">
575
+ <VTextField
576
+ v-model="form.title"
577
+ density="compact"
578
+ variant="outlined"
579
+ :label="t('dialog.linkCard.page_title')"
580
+ hide-details="auto"
581
+ />
582
+ <VTextarea
583
+ v-model="form.description"
584
+ density="compact"
585
+ variant="outlined"
586
+ :label="t('dialog.linkCard.description')"
587
+ rows="2"
588
+ max-rows="3"
589
+ auto-grow
590
+ hide-details="auto"
591
+ />
592
+ <VTextField
593
+ v-model="form.siteName"
594
+ density="compact"
595
+ variant="outlined"
596
+ :label="t('dialog.linkCard.site_name')"
597
+ hide-details="auto"
598
+ />
599
+ </div>
119
600
  </div>
120
601
 
121
- <div class="tiptapify-dialog-col">
122
- <VSelect
123
- v-model="attrs.rel"
124
- :items="relAttrs"
125
- :label="t('dialog.link.rel')"
126
- variant="outlined"
127
- multiple
128
- chips
129
- closable-chips
130
- clearable
602
+ <div v-if="isCard" class="tiptapify-dialog-col tiptapify-link-display" role="group" :aria-label="t('dialog.linkCard.layout')">
603
+ <span class="text-body-2 text-medium-emphasis">{{ t('dialog.linkCard.layout') }}</span>
604
+ <VBtnToggle
605
+ v-model="form.layout"
606
+ class="tiptapify-link-card-fields__layout"
607
+ mandatory
131
608
  density="compact"
132
- />
609
+ variant="outlined"
610
+ divided
611
+ >
612
+ <VBtn v-for="layout in layouts" :key="layout.value" :value="layout.value">
613
+ {{ layout.label }}
614
+ </VBtn>
615
+ </VBtnToggle>
616
+ </div>
617
+
618
+ <div class="tiptapify-dialog-col">
619
+ <VCheckbox v-model="form.newTab" density="compact" :label="t('dialog.link.new_tab')" hide-details />
620
+ </div>
621
+
622
+ <div v-if="!isCard" class="tiptapify-dialog-col">
623
+ <VBtn
624
+ variant="text"
625
+ size="small"
626
+ class="tiptapify-link-advanced-toggle"
627
+ :aria-expanded="advancedOpen"
628
+ :append-icon="`mdiSvg:${advancedOpen ? mdi.mdiChevronUp : mdi.mdiChevronDown}`"
629
+ @click="advancedOpen = !advancedOpen"
630
+ >
631
+ {{ t('dialog.link.advanced') }}
632
+ </VBtn>
633
+
634
+ <VExpandTransition>
635
+ <div v-show="advancedOpen" class="tiptapify-link-advanced">
636
+ <VCombobox
637
+ :model-value="form.rel"
638
+ :items="REL_SUGGESTIONS"
639
+ :delimiters="relDelimiters"
640
+ :label="t('dialog.link.rel')"
641
+ :hint="relHint"
642
+ variant="outlined"
643
+ density="compact"
644
+ multiple
645
+ chips
646
+ closable-chips
647
+ persistent-hint
648
+ hide-details="auto"
649
+ @update:model-value="updateRel"
650
+ />
651
+
652
+ <VTextField
653
+ v-model="form.cssClass"
654
+ density="compact"
655
+ variant="outlined"
656
+ :label="t('dialog.link.class')"
657
+ hide-details
658
+ />
659
+ </div>
660
+ </VExpandTransition>
133
661
  </div>
134
662
  </div>
135
663
  </VCardText>
@@ -139,20 +667,118 @@ watch(() => attrs.value.href, () => {
139
667
  <VCardActions>
140
668
  <div class="tiptapify-dialog-actions">
141
669
  <div class="tiptapify-dialog-actions__start">
142
- <VBtn v-if="editor.isActive('link')" color="warning" :variant="variantBtn" :disabled="isDisabled" @click="clear">
143
- {{ t('dialog.clear') }}
670
+ <VBtn v-if="isEdit" color="error" :variant="variantBtn" @click="remove">
671
+ {{ t(cardEdit ? 'dialog.remove' : 'dialog.link.remove') }}
144
672
  </VBtn>
145
673
  </div>
146
674
  <div class="tiptapify-dialog-actions__end">
147
675
  <VBtn :variant="variantBtn" @click="close">
148
- {{ t('dialog.close') }}
676
+ {{ t('dialog.cancel') }}
149
677
  </VBtn>
150
- <VBtn color="primary" :variant="variantBtn" :disabled="isDisabled || hrefInvalid" @click="apply">
151
- {{ t('dialog.apply') }}
678
+ <VBtn type="submit" color="primary" :variant="variantBtn" :disabled="isDisabled">
679
+ {{ isEdit ? t('dialog.save') : t('dialog.insert') }}
152
680
  </VBtn>
153
681
  </div>
154
682
  </div>
155
683
  </VCardActions>
156
684
  </template>
157
685
  </TiptapifyDialog>
158
- </template>
686
+ </template>
687
+
688
+ <style lang="scss" scoped>
689
+ .tiptapify-link-advanced-toggle {
690
+ margin-inline-start: -8px;
691
+ letter-spacing: normal;
692
+ text-transform: none;
693
+ }
694
+
695
+ .tiptapify-link-display {
696
+ display: flex;
697
+ flex-wrap: wrap;
698
+ align-items: center;
699
+ gap: 12px;
700
+ }
701
+
702
+ .tiptapify-link-protocols {
703
+ display: flex;
704
+ flex-wrap: wrap;
705
+ align-items: center;
706
+ gap: 6px;
707
+ padding: 6px 16px 0;
708
+ }
709
+
710
+ .tiptapify-link-advanced {
711
+ display: flex;
712
+ flex-direction: column;
713
+ gap: 16px;
714
+ padding-top: 12px;
715
+ }
716
+
717
+ .tiptapify-link-card-fields {
718
+ display: flex;
719
+ align-items: flex-start;
720
+ gap: 16px;
721
+ }
722
+
723
+ .tiptapify-link-card-fields__media,
724
+ .tiptapify-link-card-fields__texts {
725
+ display: flex;
726
+ flex-direction: column;
727
+ min-width: 0;
728
+ }
729
+
730
+ .tiptapify-link-card-fields__media {
731
+ flex: none;
732
+ gap: 8px;
733
+ width: 180px;
734
+ }
735
+
736
+ .tiptapify-link-card-fields__texts {
737
+ flex: 1;
738
+ gap: 12px;
739
+ }
740
+
741
+ .tiptapify-link-card-fields__image {
742
+ display: flex;
743
+ align-items: center;
744
+ justify-content: center;
745
+ overflow: hidden;
746
+ aspect-ratio: 1.91 / 1;
747
+ border-radius: 8px;
748
+ background: rgba(var(--v-theme-on-surface), 0.04);
749
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
750
+
751
+ img {
752
+ display: block;
753
+ width: 100%;
754
+ height: 100%;
755
+ object-fit: cover;
756
+ }
757
+ }
758
+
759
+ .tiptapify-link-card-fields__image-actions {
760
+ display: flex;
761
+ flex-wrap: wrap;
762
+ gap: 4px;
763
+
764
+ .v-btn {
765
+ letter-spacing: normal;
766
+ text-transform: none;
767
+ }
768
+ }
769
+
770
+ .tiptapify-link-card-fields__layout {
771
+ max-width: 100%;
772
+ }
773
+
774
+ @media (max-width: 520px) {
775
+ .tiptapify-link-card-fields {
776
+ flex-direction: column;
777
+ align-items: stretch;
778
+ }
779
+
780
+ .tiptapify-link-card-fields__media {
781
+ width: 100%;
782
+ }
783
+ }
784
+ </style>