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.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110820 -65669
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +34 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
- package/src/extensions/components/media/image/imageDialog.ts +114 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +8 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +72 -2
- package/src/utils/validateHref.ts +57 -13
|
@@ -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
|
+
}
|