tiptapify 0.3.5 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110770 -65623
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +37 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +210 -0
- package/src/extensions/components/media/image/imageDialog.ts +117 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +10 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +47 -2
- package/src/utils/validateHref.ts +47 -7
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Editor, Extension } from '@tiptap/core'
|
|
2
|
+
import { EditorState, Plugin, PluginKey, Transaction } from '@tiptap/pm/state'
|
|
3
|
+
import { LinkCardAttrs } from '@tiptapify/extensions/components/media/linkCard'
|
|
4
|
+
|
|
5
|
+
// A link pasted alone into an empty paragraph: its paragraph (mapped through later changes) and its address
|
|
6
|
+
export type PastedLinkState = { pos: number, url: string }
|
|
7
|
+
|
|
8
|
+
// The pasted link the menu offers to show as a card: the paragraph range and the link text range in it
|
|
9
|
+
export type PastedLink = { url: string, from: number, to: number, paragraphFrom: number, paragraphTo: number }
|
|
10
|
+
|
|
11
|
+
// The class of the paste menu: the editor losing focus to it keeps the pasted link
|
|
12
|
+
export const PASTE_CARD_MENU_CLASS = 'tiptapify-link-card-paste'
|
|
13
|
+
|
|
14
|
+
export const linkCardPasteKey = new PluginKey<PastedLinkState | null>('linkCardPaste')
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The text as one http(s) address, as typed (no protocol of our own), or null: anything else, spaces
|
|
18
|
+
* inside included, is ordinary text.
|
|
19
|
+
*/
|
|
20
|
+
export function pastedLinkUrl(text: string | null | undefined): string | null {
|
|
21
|
+
const url = (text ?? '').trim()
|
|
22
|
+
|
|
23
|
+
if (!url || /\s/.test(url)) {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const { protocol } = new URL(url)
|
|
29
|
+
|
|
30
|
+
return protocol === 'http:' || protocol === 'https:' ? url : null
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isEmptyParagraph(state: EditorState): boolean {
|
|
37
|
+
const { selection } = state
|
|
38
|
+
const parent = selection.$from.parent
|
|
39
|
+
|
|
40
|
+
return selection.empty && parent.type.name === 'paragraph' && parent.content.size === 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The paragraph a paste into an empty paragraph produced, when it holds just one address
|
|
44
|
+
function trackPaste(tr: Transaction, oldState: EditorState): PastedLinkState | null {
|
|
45
|
+
if (!isEmptyParagraph(oldState)) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { $from } = tr.selection
|
|
50
|
+
const url = $from.parent.type.name === 'paragraph' ? pastedLinkUrl($from.parent.textContent) : null
|
|
51
|
+
|
|
52
|
+
return url && $from.depth > 0 ? { pos: $from.before(), url } : null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function blurredToMenu(meta: unknown): boolean {
|
|
56
|
+
const target = (meta as { event?: FocusEvent } | undefined)?.event?.relatedTarget
|
|
57
|
+
|
|
58
|
+
return target instanceof Element && !!target.closest(`.${PASTE_CARD_MENU_CLASS}`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function applyTransaction(tr: Transaction, value: PastedLinkState | null, oldState: EditorState): PastedLinkState | null {
|
|
62
|
+
if (tr.getMeta('paste') || tr.getMeta('uiEvent') === 'paste') {
|
|
63
|
+
return trackPaste(tr, oldState)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!value || tr.getMeta(linkCardPasteKey)?.dismiss) {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// What the paste brought along: the link mark of the paste rules
|
|
71
|
+
if (tr.getMeta('appendedTransaction')) {
|
|
72
|
+
return { ...value, pos: tr.mapping.map(value.pos) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Typing, a moved selection or the editor left for anything but the menu
|
|
76
|
+
if (tr.docChanged || !tr.selection.eq(oldState.selection) || (tr.getMeta('blur') && !blurredToMenu(tr.getMeta('blur')))) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The pasted link with its ranges, while it is still the whole text of its paragraph.
|
|
85
|
+
*/
|
|
86
|
+
export function pastedLink(state: EditorState): PastedLink | null {
|
|
87
|
+
const tracked = linkCardPasteKey.getState(state)
|
|
88
|
+
const node = tracked ? state.doc.nodeAt(tracked.pos) : null
|
|
89
|
+
|
|
90
|
+
if (!tracked || !node || node.type.name !== 'paragraph' || pastedLinkUrl(node.textContent) !== tracked.url) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const paragraphTo = tracked.pos + node.nodeSize
|
|
95
|
+
|
|
96
|
+
return { url: tracked.url, from: tracked.pos + 1, to: paragraphTo - 1, paragraphFrom: tracked.pos, paragraphTo }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function dismissPastedLink(editor: Editor): void {
|
|
100
|
+
if (linkCardPasteKey.getState(editor.state)) {
|
|
101
|
+
editor.view.dispatch(editor.state.tr.setMeta(linkCardPasteKey, { dismiss: true }).setMeta('addToHistory', false))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The paragraph of the pasted link replaced with the card of that link; false when the link is gone
|
|
107
|
+
* (the author typed or moved on meanwhile).
|
|
108
|
+
*/
|
|
109
|
+
export function replacePastedLink(editor: Editor, attrs: LinkCardAttrs): boolean {
|
|
110
|
+
const link = pastedLink(editor.state)
|
|
111
|
+
|
|
112
|
+
if (!link || link.url !== attrs.href) {
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return editor.chain()
|
|
117
|
+
.focus()
|
|
118
|
+
.insertContentAt({ from: link.paragraphFrom, to: link.paragraphTo }, { type: 'linkCard', attrs })
|
|
119
|
+
.setMeta(linkCardPasteKey, { dismiss: true })
|
|
120
|
+
.run()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Remembers a link pasted alone into an empty paragraph, so the paste menu can offer it as a card. The
|
|
125
|
+
* paste itself is left as it is (a link); nothing is replaced unless the author picks the card. Any
|
|
126
|
+
* typing, a moved selection, leaving the editor or Esc forgets it.
|
|
127
|
+
*/
|
|
128
|
+
export const LinkCardPaste = Extension.create({
|
|
129
|
+
name: 'linkCardPaste',
|
|
130
|
+
|
|
131
|
+
addProseMirrorPlugins() {
|
|
132
|
+
return [
|
|
133
|
+
new Plugin<PastedLinkState | null>({
|
|
134
|
+
key: linkCardPasteKey,
|
|
135
|
+
state: {
|
|
136
|
+
init: () => null,
|
|
137
|
+
apply: (tr, value, oldState) => applyTransaction(tr, value, oldState),
|
|
138
|
+
},
|
|
139
|
+
props: {
|
|
140
|
+
handleKeyDown: (view, event) => {
|
|
141
|
+
if (event.key !== 'Escape' || !linkCardPasteKey.getState(view.state)) {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
view.dispatch(view.state.tr.setMeta(linkCardPasteKey, { dismiss: true }).setMeta('addToHistory', false))
|
|
146
|
+
|
|
147
|
+
return true
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
151
|
+
]
|
|
152
|
+
},
|
|
153
|
+
})
|
|
@@ -4,6 +4,7 @@ import { Editor } from '@tiptap/vue-3'
|
|
|
4
4
|
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
5
5
|
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
6
6
|
import VideoDialog from '@tiptapify/extensions/components/media/video/VideoDialog.vue'
|
|
7
|
+
import { selectedVideoNode } from '@tiptapify/extensions/components/media/video'
|
|
7
8
|
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
9
|
import { inject, Ref } from 'vue'
|
|
9
10
|
|
|
@@ -20,7 +21,7 @@ const icon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" vie
|
|
|
20
21
|
|
|
21
22
|
<template>
|
|
22
23
|
<VBtn
|
|
23
|
-
:color="editor
|
|
24
|
+
:color="selectedVideoNode(editor) ? 'primary' : ''"
|
|
24
25
|
:variant="variantBtn"
|
|
25
26
|
size="32"
|
|
26
27
|
@click="editor.commands.showTiptapifyVideo()"
|
|
@@ -0,0 +1,233 @@
|
|
|
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 { Youtube } from '@tiptap/extension-youtube'
|
|
9
|
+
import Iframe from '@tiptapify/extensions/components/media/iframe'
|
|
10
|
+
import { TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
|
|
11
|
+
import VideoDialog from '@tiptapify/extensions/components/media/video/VideoDialog.vue'
|
|
12
|
+
import { VideoFile } from '@tiptapify/extensions/components/media/video/videoFile'
|
|
13
|
+
import { fakeUploadEndpoint } from '@tiptapify/composables/fakeUploadEndpoint'
|
|
14
|
+
import { TIPTAPIFY_UPLOAD_KEY } from '@tiptapify/composables/useTiptapifyUpload'
|
|
15
|
+
import { TiptapifyUploadOptions } from '@tiptapify/types/editor'
|
|
16
|
+
|
|
17
|
+
// jsdom has no `visualViewport`; Vuetify's overlay positioning reads it
|
|
18
|
+
if (!('visualViewport' in globalThis)) {
|
|
19
|
+
;(globalThis as Record<string, unknown>).visualViewport = {
|
|
20
|
+
scale: 1,
|
|
21
|
+
offsetLeft: 0,
|
|
22
|
+
offsetTop: 0,
|
|
23
|
+
addEventListener: () => {},
|
|
24
|
+
removeEventListener: () => {},
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let app: App | null = null
|
|
29
|
+
let editor: Editor | null = null
|
|
30
|
+
|
|
31
|
+
async function flush() {
|
|
32
|
+
for (let i = 0; i < 5; i++) {
|
|
33
|
+
await nextTick()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function openDialog(content: string, upload: TiptapifyUploadOptions | null = null, selectFirst = false): Promise<HTMLElement> {
|
|
38
|
+
editor = new Editor({
|
|
39
|
+
element: document.createElement('div'),
|
|
40
|
+
content,
|
|
41
|
+
extensions: [Document, Paragraph, Text, Youtube.configure({ nocookie: true }), Iframe, VideoFile, TiptapifyVideo],
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
app = createApp({
|
|
45
|
+
setup() {
|
|
46
|
+
provide('tiptapifyEditor', shallowRef(editor))
|
|
47
|
+
provide('tiptapifyI18n', { t: (key: string) => key })
|
|
48
|
+
provide(TIPTAPIFY_UPLOAD_KEY, ref(upload))
|
|
49
|
+
|
|
50
|
+
return () => h(VideoDialog)
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
app.use(createVuetify())
|
|
54
|
+
app.mount(document.body.appendChild(document.createElement('div')))
|
|
55
|
+
await flush()
|
|
56
|
+
|
|
57
|
+
if (selectFirst) {
|
|
58
|
+
editor.commands.setNodeSelection(0)
|
|
59
|
+
}
|
|
60
|
+
editor.commands.showTiptapifyVideo()
|
|
61
|
+
await flush()
|
|
62
|
+
|
|
63
|
+
return document.querySelector<HTMLElement>('.tiptapify-dialog')!
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function type(input: HTMLInputElement, value: string) {
|
|
67
|
+
input.value = value
|
|
68
|
+
input.dispatchEvent(new Event('input'))
|
|
69
|
+
await flush()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function submit(card: HTMLElement) {
|
|
73
|
+
card.querySelector('form')!.requestSubmit()
|
|
74
|
+
await flush()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buttonWithText(card: HTMLElement, text: string): HTMLButtonElement {
|
|
78
|
+
return [...card.querySelectorAll('button')].find(button => button.textContent?.includes(text)) as HTMLButtonElement
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
vi.unstubAllGlobals()
|
|
83
|
+
app?.unmount()
|
|
84
|
+
editor?.destroy()
|
|
85
|
+
app = null
|
|
86
|
+
editor = null
|
|
87
|
+
document.body.innerHTML = ''
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('video dialog', () => {
|
|
91
|
+
it('keeps the dialog open with an inline error for an unsupported link', async () => {
|
|
92
|
+
const card = await openDialog('<p></p>')
|
|
93
|
+
|
|
94
|
+
await type(card.querySelector('input')!, 'https://example.com/clip.html')
|
|
95
|
+
await submit(card)
|
|
96
|
+
|
|
97
|
+
expect(card.textContent).toContain('dialog.video.url_unsupported')
|
|
98
|
+
expect(document.querySelector('.tiptapify-dialog')).not.toBeNull()
|
|
99
|
+
expect(editor!.getHTML()).toBe('<p></p>')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('inserts a responsive YouTube video starting at the link time', async () => {
|
|
103
|
+
const card = await openDialog('<p></p>')
|
|
104
|
+
|
|
105
|
+
await type(card.querySelector('input')!, 'https://youtu.be/dQw4w9WgXcQ?t=42')
|
|
106
|
+
|
|
107
|
+
expect(card.textContent).toContain('YouTube')
|
|
108
|
+
expect(card.querySelector<HTMLInputElement>('input[placeholder="0:00"]')!.value).toBe('0:42')
|
|
109
|
+
|
|
110
|
+
await submit(card)
|
|
111
|
+
|
|
112
|
+
const youtube = editor!.state.doc.firstChild!
|
|
113
|
+
|
|
114
|
+
expect(youtube.type.name).toBe('youtube')
|
|
115
|
+
expect(youtube.attrs).toMatchObject({ src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', start: 42, width: null, height: null })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('keeps the custom size in its ratio until the lock is opened', async () => {
|
|
119
|
+
const card = await openDialog('<p></p>')
|
|
120
|
+
await type(card.querySelector('input')!, 'https://youtu.be/dQw4w9WgXcQ')
|
|
121
|
+
buttonWithText(card, 'dialog.video.size_custom').click()
|
|
122
|
+
await flush()
|
|
123
|
+
|
|
124
|
+
const [width, height] = card.querySelectorAll<HTMLInputElement>('input[type="number"]')
|
|
125
|
+
expect([width.value, height.value]).toEqual(['640', '360'])
|
|
126
|
+
|
|
127
|
+
await type(width, '1280')
|
|
128
|
+
expect(height.value).toBe('720')
|
|
129
|
+
|
|
130
|
+
card.querySelector<HTMLButtonElement>('[aria-label="dialog.video.keep_ratio"]')!.click()
|
|
131
|
+
await flush()
|
|
132
|
+
await type(width, '800')
|
|
133
|
+
expect(height.value).toBe('720')
|
|
134
|
+
|
|
135
|
+
await submit(card)
|
|
136
|
+
expect(editor!.getJSON().content!.find(node => node.type === 'youtube')!.attrs).toMatchObject({ width: 800, height: 720 })
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('inserts a link to a video file as a video node with the file options', async () => {
|
|
140
|
+
const card = await openDialog('<p></p>')
|
|
141
|
+
await type(card.querySelector('input')!, 'https://www.w3schools.com/tags/mov_bbb.mp4#t=5')
|
|
142
|
+
|
|
143
|
+
expect(card.querySelector('.tiptapify-video-dialog__preview video')!.getAttribute('src')).toBe('https://www.w3schools.com/tags/mov_bbb.mp4#t=5')
|
|
144
|
+
const loop = [...card.querySelectorAll('.v-checkbox')].find(box => box.textContent?.includes('dialog.video.loop'))!
|
|
145
|
+
loop.querySelector('input')!.click()
|
|
146
|
+
await flush()
|
|
147
|
+
|
|
148
|
+
await submit(card)
|
|
149
|
+
|
|
150
|
+
const video = editor!.getJSON().content!.find(node => node.type === 'video')!
|
|
151
|
+
expect(video.attrs).toMatchObject({ src: 'https://www.w3schools.com/tags/mov_bbb.mp4', start: 5, controls: true, loop: true })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('opens a video linked by its file address on the link tab, even without the upload', async () => {
|
|
155
|
+
const card = await openDialog('<video src="https://www.w3schools.com/tags/mov_bbb.mp4#t=5" controls></video>', null, true)
|
|
156
|
+
|
|
157
|
+
expect(card.querySelector<HTMLInputElement>('input')!.value).toBe('https://www.w3schools.com/tags/mov_bbb.mp4')
|
|
158
|
+
expect(card.querySelector<HTMLInputElement>('input[placeholder="0:00"]')!.value).toBe('0:05')
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('offers the upload only when the editor has an upload endpoint', async () => {
|
|
162
|
+
const card = await openDialog('<p></p>')
|
|
163
|
+
|
|
164
|
+
expect(buttonWithText(card, 'dialog.video.source_upload')).toBeUndefined()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('uploads a video file to the endpoint and inserts it as a video node', async () => {
|
|
168
|
+
const requests = fakeUploadEndpoint(() => ({ status: 201, body: { url: 'https://cdn.example.com/clip.mp4' } }))
|
|
169
|
+
const card = await openDialog('<p></p>', { endpoint: '/api/uploads' })
|
|
170
|
+
|
|
171
|
+
buttonWithText(card, 'dialog.video.source_upload').click()
|
|
172
|
+
await flush()
|
|
173
|
+
|
|
174
|
+
const input = card.querySelector<HTMLInputElement>('input[accept="video/*"]')!
|
|
175
|
+
Object.defineProperty(input, 'files', { value: [new File(['x'], 'clip.mp4', { type: 'video/mp4' })] })
|
|
176
|
+
input.dispatchEvent(new Event('change'))
|
|
177
|
+
await new Promise(resolve => setTimeout(resolve))
|
|
178
|
+
await flush()
|
|
179
|
+
|
|
180
|
+
expect(requests).toHaveLength(1)
|
|
181
|
+
expect(requests[0].body.get('kind')).toBe('video')
|
|
182
|
+
expect(card.textContent).toContain('clip.mp4')
|
|
183
|
+
|
|
184
|
+
await submit(card)
|
|
185
|
+
|
|
186
|
+
expect(editor!.state.doc.firstChild!.type.name).toBe('video')
|
|
187
|
+
expect(editor!.state.doc.firstChild!.attrs).toMatchObject({ src: 'https://cdn.example.com/clip.mp4', controls: true, width: null })
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('shows the error the upload endpoint answers and inserts nothing', async () => {
|
|
191
|
+
fakeUploadEndpoint(() => ({ status: 413, body: { error: 'The file is too large.' } }))
|
|
192
|
+
const card = await openDialog('<p></p>', { endpoint: '/api/uploads' })
|
|
193
|
+
|
|
194
|
+
buttonWithText(card, 'dialog.video.source_upload').click()
|
|
195
|
+
await flush()
|
|
196
|
+
|
|
197
|
+
const input = card.querySelector<HTMLInputElement>('input[accept="video/*"]')!
|
|
198
|
+
Object.defineProperty(input, 'files', { value: [new File(['x'], 'clip.mp4', { type: 'video/mp4' })] })
|
|
199
|
+
input.dispatchEvent(new Event('change'))
|
|
200
|
+
await new Promise(resolve => setTimeout(resolve))
|
|
201
|
+
await flush()
|
|
202
|
+
|
|
203
|
+
expect(card.querySelector('[role="alert"]')!.textContent).toContain('The file is too large.')
|
|
204
|
+
|
|
205
|
+
await submit(card)
|
|
206
|
+
|
|
207
|
+
expect(editor!.getHTML()).toBe('<p></p>')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('edits the selected video in place, keeping the attributes it does not know', async () => {
|
|
211
|
+
const card = await openDialog('<video src="/a.mp4" controls></video>', null, true)
|
|
212
|
+
|
|
213
|
+
expect(card.textContent).toContain('dialog.video.title_edit')
|
|
214
|
+
expect(card.querySelector<HTMLInputElement>('input')!.value).toBe('/a.mp4')
|
|
215
|
+
|
|
216
|
+
const loop = [...card.querySelectorAll('.v-checkbox')].find(box => box.textContent?.includes('dialog.video.loop'))!
|
|
217
|
+
loop.querySelector('input')!.click()
|
|
218
|
+
await flush()
|
|
219
|
+
await submit(card)
|
|
220
|
+
|
|
221
|
+
expect(editor!.state.doc.childCount).toBe(1)
|
|
222
|
+
expect(editor!.state.doc.firstChild!.attrs).toMatchObject({ src: '/a.mp4', controls: true, loop: true })
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('removes the selected video', async () => {
|
|
226
|
+
const card = await openDialog('<video src="/a.mp4"></video><p>Text</p>', null, true)
|
|
227
|
+
|
|
228
|
+
buttonWithText(card, 'dialog.remove').click()
|
|
229
|
+
await flush()
|
|
230
|
+
|
|
231
|
+
expect(editor!.getHTML()).toBe('<p>Text</p>')
|
|
232
|
+
})
|
|
233
|
+
})
|