tiptapify 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110820 -65669
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +34 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +114 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +8 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +72 -2
  137. package/src/utils/validateHref.ts +57 -13
@@ -1,72 +1,438 @@
1
1
  <script setup lang="ts">
2
2
 
3
+ import * as mdi from '@mdi/js'
3
4
  import { Editor } from '@tiptap/vue-3'
4
5
  import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
5
6
  import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
7
+ import { useTiptapifyUpload } from '@tiptapify/composables/useTiptapifyUpload'
8
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
9
+ import { ratioOf, resizeSide, toSize } from '@tiptapify/utils/mediaSize'
10
+ import { applyVideoNode } from '@tiptapify/extensions/components/media/video'
11
+ import {
12
+ formatTimecode,
13
+ isVideoFileWithoutProtocol,
14
+ parseTimecode,
15
+ parseVideoUrl,
16
+ VideoNodeContent,
17
+ VideoSize,
18
+ } from '@tiptapify/extensions/components/media/video/providers'
6
19
 
7
- import { computed, inject, onMounted, onUnmounted, Ref, ref, useTemplateRef } from 'vue'
20
+ import { computed, inject, onMounted, onUnmounted, reactive, Ref, ref, useId, useTemplateRef, watch } from 'vue'
8
21
  import { ComposerTranslation } from 'vue-i18n'
9
22
 
10
- const { variantBtn, variantField } = useTiptapifyConfig()
23
+ type Source = 'url' | 'upload'
24
+ type SizeMode = 'responsive' | 'custom'
25
+
26
+ const { variantBtn } = useTiptapifyConfig()
27
+ // A video file needs the upload prop; a poster without it is embedded as base64
28
+ const { canUploadVideo: canUpload, uploadFile } = useTiptapifyUpload()
11
29
 
12
30
  const editor = inject('tiptapifyEditor') as Ref<Editor>
13
31
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
14
32
 
15
- type videoAttrs = {
16
- src: string,
17
- width?: number,
18
- height?: number
19
- }
20
- const generateVideoAttrs = () => ({
33
+ const dialog = useTemplateRef('dialog')
34
+ const videoInput = useTemplateRef<HTMLInputElement>('videoInput')
35
+ const posterInput = useTemplateRef<HTMLInputElement>('posterInput')
36
+ const startId = useId()
37
+
38
+ const generateForm = () => ({
39
+ source: 'url' as Source,
40
+ url: '',
41
+ start: '',
42
+ sizeMode: 'responsive' as SizeMode,
43
+ width: null as number | string | null,
44
+ height: null as number | string | null,
45
+ poster: '' as string | null,
46
+ controls: true,
47
+ autoplay: false,
48
+ loop: false,
49
+ })
50
+
51
+ const generateFile = () => ({
21
52
  src: '',
22
- height: null,
23
- width: null
53
+ name: '',
54
+ size: 0,
55
+ progress: 0,
56
+ uploading: false,
57
+ error: '',
24
58
  })
25
59
 
26
- const attrs = ref(generateVideoAttrs())
60
+ const form = ref(generateForm())
61
+ // The video file: `src` is set once its upload is done (or by the edited node)
62
+ const file = reactive(generateFile())
63
+ const poster = reactive({ uploading: false, error: '' })
64
+ // Field errors show once the field was left or the form submitted, not while typing
65
+ const touched = reactive({ url: false, start: false, size: false })
66
+ const applyError = ref('')
67
+ const edited = ref<VideoNodeContent | null>(null)
27
68
 
28
- const dialog = useTemplateRef('dialog')
69
+ let videoUpload: AbortController | null = null
70
+ let posterUpload: AbortController | null = null
71
+
72
+ const isEdit = computed(() => edited.value !== null)
73
+ // An edited video file keeps its tab even when the editor takes no uploads
74
+ const showUpload = computed(() => canUpload.value || edited.value?.type === 'video')
75
+ const isUpload = computed(() => showUpload.value && form.value.source === 'upload')
76
+
77
+ const link = computed(() => parseVideoUrl(form.value.url))
78
+ // A link straight to a video file: the browser's own player, with the options of an uploaded file
79
+ const isFileLink = computed(() => !isUpload.value && link.value?.provider.nodeType === 'video')
80
+ const hasFileOptions = computed(() => isUpload.value || isFileLink.value)
81
+ const supportsStart = computed(() => isUpload.value || !!link.value?.provider.supportsStart)
82
+ const startSeconds = computed(() => parseTimecode(form.value.start))
83
+
84
+ const urlError = computed(() => {
85
+ if (isUpload.value || !touched.url) {
86
+ return ''
87
+ }
88
+
89
+ if (!form.value.url.trim()) {
90
+ return t('dialog.video.url_required')
91
+ }
92
+
93
+ if (link.value) {
94
+ return ''
95
+ }
29
96
 
30
- const isDisabled = computed(() => {
31
- const { src } = attrs.value
32
- return !src
97
+ return t(isVideoFileWithoutProtocol(form.value.url) ? 'dialog.video.url_file_protocol' : 'dialog.video.url_unsupported')
33
98
  })
34
99
 
35
- function apply() {
36
- let { src, width, height } = attrs.value
100
+ const startError = computed(() => (
101
+ touched.start && supportsStart.value && startSeconds.value === null ? t('dialog.video.start_invalid') : ''
102
+ ))
103
+
104
+ const widthError = computed(() => (
105
+ touched.size && form.value.sizeMode === 'custom' && !toSize(form.value.width) ? t('dialog.video.size_invalid') : ''
106
+ ))
107
+
108
+ const heightError = computed(() => {
109
+ const { sizeMode, height } = form.value
110
+ const filled = String(height ?? '').trim() !== ''
37
111
 
38
- const videoOptions: videoAttrs = { src }
112
+ return touched.size && sizeMode === 'custom' && filled && !toSize(height) ? t('dialog.video.size_invalid') : ''
113
+ })
39
114
 
40
- if (width) {
41
- videoOptions.width = width
115
+ const previewUrl = computed(() => {
116
+ if (!link.value) {
117
+ return ''
42
118
  }
43
119
 
44
- if (height) {
45
- videoOptions.height = height
120
+ const start = supportsStart.value ? (startSeconds.value ?? link.value.start) : 0
121
+
122
+ return link.value.provider.embedUrl(link.value.id, start)
123
+ })
124
+
125
+ const sizeModes = computed(() => [
126
+ { value: 'responsive', label: t(hasFileOptions.value ? 'dialog.video.size_responsive' : 'dialog.video.size_responsive_embed') },
127
+ { value: 'custom', label: t('dialog.video.size_custom') },
128
+ ])
129
+
130
+ const progressLabel = computed(() => t('dialog.video.upload_progress', {
131
+ loaded: megabytes(file.size * file.progress),
132
+ total: megabytes(file.size),
133
+ }))
134
+
135
+ const canSubmit = computed(() => {
136
+ if (isUpload.value) {
137
+ return !!file.src && !file.uploading && !poster.uploading
46
138
  }
47
139
 
140
+ return !!form.value.url.trim()
141
+ })
142
+
143
+ watch(link, (current, previous) => {
144
+ if (current?.start && current.start !== previous?.start) {
145
+ form.value.start = formatTimecode(current.start)
146
+ }
147
+ })
148
+
149
+ function megabytes(bytes: number): string {
150
+ const value = bytes / 1024 / 1024
151
+
152
+ return value < 10 ? value.toFixed(1) : String(Math.round(value))
153
+ }
154
+
155
+ function fileName(src: string): string {
156
+ const name = src.split(/[?#]/)[0].split('/').pop() || src
157
+
158
+ try {
159
+ return decodeURIComponent(name)
160
+ } catch {
161
+ return name
162
+ }
163
+ }
164
+
165
+ // The custom size keeps its ratio while locked: the ratio of the size being edited, else of the uploaded
166
+ // file itself, else 16:9 of the embedded players
167
+ const keepRatio = ref(true)
168
+ const ratio = ref(16 / 9)
169
+ const fileRatio = ref<number | null>(null)
170
+
171
+ function selectSizeMode(mode: SizeMode) {
172
+ form.value.sizeMode = mode
173
+
174
+ if (mode === 'custom' && !toSize(form.value.width)) {
175
+ ratio.value = (hasFileOptions.value && fileRatio.value) || 16 / 9
176
+ form.value.width = 640
177
+ form.value.height = toSize(640 / ratio.value)
178
+ }
179
+ }
180
+
181
+ function resize(side: 'width' | 'height', value: unknown) {
182
+ const current = { width: toSize(form.value.width), height: toSize(form.value.height) }
183
+ Object.assign(form.value, resizeSide(current, side, value, keepRatio.value ? ratio.value : null))
184
+ }
185
+
186
+ function toggleKeepRatio() {
187
+ keepRatio.value = !keepRatio.value
188
+
189
+ if (keepRatio.value) {
190
+ ratio.value = ratioOf({ width: toSize(form.value.width), height: toSize(form.value.height) }, ratio.value)
191
+ }
192
+ }
193
+
194
+ function readFileRatio(src: string, isCurrent: () => boolean) {
195
+ const probe = document.createElement('video')
196
+ probe.preload = 'metadata'
197
+ probe.onloadedmetadata = () => {
198
+ if (isCurrent() && probe.videoWidth && probe.videoHeight) {
199
+ fileRatio.value = probe.videoWidth / probe.videoHeight
200
+ }
201
+ }
202
+ probe.src = src
203
+ }
204
+
205
+ watch(() => (isFileLink.value ? link.value!.id : ''), (src) => {
206
+ fileRatio.value = null
48
207
  if (src) {
49
- editor.value.commands.setYoutubeVideo(videoOptions)
50
- // editor.value.commands.setYoutubeVideo({
51
- // src: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
52
- // width: 640,
53
- // height: 480,
54
- // })
208
+ readFileRatio(src, () => isFileLink.value && link.value?.id === src)
209
+ }
210
+ })
211
+
212
+ function size(embed: boolean): VideoSize {
213
+ if (form.value.sizeMode === 'responsive') {
214
+ return { width: null, height: null }
215
+ }
216
+
217
+ const width = toSize(form.value.width)
218
+ // A player without a height is 150px tall: an embed gets a 16:9 one
219
+ const height = toSize(form.value.height) ?? (embed && width ? Math.round(width * 9 / 16) : null)
220
+
221
+ return { width, height }
222
+ }
223
+
224
+ function videoContent(): VideoNodeContent | null {
225
+ Object.assign(touched, { url: true, start: true, size: true })
226
+
227
+ if (startError.value || widthError.value || heightError.value) {
228
+ return null
229
+ }
230
+
231
+ const start = startSeconds.value ?? 0
232
+
233
+ const { controls, autoplay, loop } = form.value
234
+ const fileOptions = { poster: form.value.poster?.trim() || null, controls, autoplay, loop }
235
+
236
+ if (!isUpload.value) {
237
+ if (!link.value || poster.uploading) {
238
+ return null
239
+ }
240
+
241
+ const content = link.value.provider.toNode(link.value.id, supportsStart.value ? start : 0, size(!isFileLink.value))
242
+
243
+ return isFileLink.value ? { ...content, attrs: { ...content.attrs, ...fileOptions } } : content
244
+ }
245
+
246
+ if (!file.src || file.uploading || poster.uploading) {
247
+ return null
248
+ }
249
+
250
+ return { type: 'video', attrs: { src: file.src, start, ...fileOptions, ...size(false) } }
251
+ }
252
+
253
+ function apply() {
254
+ applyError.value = ''
255
+
256
+ const content = videoContent()
257
+
258
+ if (!content) {
259
+ return
260
+ }
261
+
262
+ if (!applyVideoNode(editor.value, content, edited.value)) {
263
+ applyError.value = t('dialog.video.apply_failed')
264
+
265
+ return
55
266
  }
56
267
 
57
268
  close()
58
269
  }
59
270
 
60
- function clear() {
61
- editor.value.commands.deleteSelection()
271
+ function remove() {
272
+ editor.value.chain().focus().deleteSelection().run()
62
273
 
63
274
  close()
64
275
  }
65
276
 
66
277
  function close() {
67
- dialog.value.close()
278
+ dialog.value?.close()
279
+ }
280
+
281
+ function cancelVideoUpload() {
282
+ videoUpload?.abort()
283
+ videoUpload = null
284
+ Object.assign(file, generateFile())
285
+ }
68
286
 
69
- attrs.value = generateVideoAttrs()
287
+ function cancelPosterUpload() {
288
+ posterUpload?.abort()
289
+ posterUpload = null
290
+ poster.uploading = false
291
+ }
292
+
293
+ function reset() {
294
+ cancelVideoUpload()
295
+ cancelPosterUpload()
296
+
297
+ form.value = generateForm()
298
+ poster.error = ''
299
+ Object.assign(touched, { url: false, start: false, size: false })
300
+ applyError.value = ''
301
+ edited.value = null
302
+ keepRatio.value = true
303
+ ratio.value = 16 / 9
304
+ fileRatio.value = null
305
+ }
306
+
307
+ function pickedFile(event: Event): File | null {
308
+ const input = event.target as HTMLInputElement
309
+ const picked = input.files?.[0] ?? null
310
+
311
+ // Picking the same file again (after a cancel) must fire `change` again
312
+ input.value = ''
313
+
314
+ return picked
315
+ }
316
+
317
+ function uploadErrorMessage(error: unknown): string {
318
+ return error instanceof Error && error.message ? error.message : t('dialog.video.upload_failed')
319
+ }
320
+
321
+ async function uploadVideo(event: Event) {
322
+ const picked = pickedFile(event)
323
+
324
+ if (!picked || !canUpload.value) {
325
+ return
326
+ }
327
+
328
+ cancelVideoUpload()
329
+
330
+ const controller = new AbortController()
331
+ videoUpload = controller
332
+ Object.assign(file, { name: picked.name, size: picked.size, uploading: true })
333
+ fileRatio.value = null
334
+
335
+ // A canceled or replaced upload (another controller) leaves the form alone when it settles
336
+ try {
337
+ const { url } = await uploadFile(picked, {
338
+ kind: 'video',
339
+ onProgress: (fraction) => {
340
+ if (videoUpload === controller) {
341
+ file.progress = fraction
342
+ }
343
+ },
344
+ signal: controller.signal,
345
+ })
346
+
347
+ if (videoUpload === controller) {
348
+ file.src = url
349
+ readFileRatio(url, () => file.src === url)
350
+ }
351
+ } catch (error) {
352
+ // The endpoint's own message tells best what went wrong
353
+ if (videoUpload === controller) {
354
+ file.error = uploadErrorMessage(error)
355
+ }
356
+ } finally {
357
+ if (videoUpload === controller) {
358
+ videoUpload = null
359
+ file.uploading = false
360
+ }
361
+ }
362
+ }
363
+
364
+ async function uploadPoster(event: Event) {
365
+ const picked = pickedFile(event)
366
+
367
+ if (!picked) {
368
+ return
369
+ }
370
+
371
+ cancelPosterUpload()
372
+
373
+ const controller = new AbortController()
374
+ posterUpload = controller
375
+ poster.uploading = true
376
+ poster.error = ''
377
+
378
+ try {
379
+ const { url } = await uploadFile(picked, { kind: 'image', onProgress: () => {}, signal: controller.signal })
380
+
381
+ if (posterUpload === controller) {
382
+ form.value.poster = url
383
+ }
384
+ } catch (error) {
385
+ if (posterUpload === controller) {
386
+ poster.error = uploadErrorMessage(error)
387
+ }
388
+ } finally {
389
+ if (posterUpload === controller) {
390
+ posterUpload = null
391
+ poster.uploading = false
392
+ }
393
+ }
394
+ }
395
+
396
+ function prefill(node: VideoNodeContent) {
397
+ const { attrs } = node
398
+ const width = toSize(attrs.width)
399
+
400
+ edited.value = node
401
+
402
+ Object.assign(form.value, {
403
+ source: node.type === 'video' ? 'upload' : 'url',
404
+ start: formatTimecode(Number(attrs.start) || 0),
405
+ sizeMode: width ? 'custom' : 'responsive',
406
+ width,
407
+ height: toSize(attrs.height),
408
+ })
409
+ ratio.value = ratioOf({ width, height: toSize(attrs.height) }, 16 / 9)
410
+
411
+ if (node.type !== 'video') {
412
+ // A Vimeo player link carries its start (`#t=42s`), which the link watcher fills in
413
+ form.value.url = String(attrs.src ?? '')
414
+
415
+ return
416
+ }
417
+
418
+ Object.assign(form.value, {
419
+ poster: String(attrs.poster ?? ''),
420
+ controls: attrs.controls !== false,
421
+ autoplay: !!attrs.autoplay,
422
+ loop: !!attrs.loop,
423
+ })
424
+
425
+ // A video linked by its file address opens as that link; an uploaded one (an address without a
426
+ // video extension, e.g. /media/123) opens on the upload tab
427
+ const src = String(attrs.src ?? '')
428
+ if (parseVideoUrl(src)?.provider.nodeType === 'video') {
429
+ Object.assign(form.value, { source: 'url', url: src })
430
+
431
+ return
432
+ }
433
+
434
+ file.src = src
435
+ file.name = fileName(file.src)
70
436
  }
71
437
 
72
438
  const showTiptapifyVideo = (event: CustomEvent) => {
@@ -74,11 +440,13 @@ const showTiptapifyVideo = (event: CustomEvent) => {
74
440
  return
75
441
  }
76
442
 
77
- attrs.value.src = event.detail.video?.src
78
- attrs.value.width = event.detail.video?.width
79
- attrs.value.height = event.detail.video?.height
443
+ reset()
80
444
 
81
- dialog.value.open()
445
+ if (event.detail.node) {
446
+ prefill(event.detail.node)
447
+ }
448
+
449
+ dialog.value?.open()
82
450
  }
83
451
 
84
452
  onMounted(() => {
@@ -87,44 +455,237 @@ onMounted(() => {
87
455
 
88
456
  onUnmounted(() => {
89
457
  window.removeEventListener('tiptapify-show-tiptapifyVideo', showTiptapifyVideo as EventListener)
458
+
459
+ cancelVideoUpload()
460
+ cancelPosterUpload()
90
461
  })
91
462
  </script>
92
463
 
93
464
  <template>
94
- <TiptapifyDialog ref="dialog" module="video" :max-width="800">
465
+ <TiptapifyDialog
466
+ ref="dialog"
467
+ module="video"
468
+ :title="t(isEdit ? 'dialog.video.title_edit' : 'dialog.video.title_insert')"
469
+ :max-width="560"
470
+ @submit="apply"
471
+ @closed="reset"
472
+ >
95
473
  <template #content>
96
474
  <VCardText>
97
- <div class="tiptapify-dialog-grid">
98
- <!-- <div class="tiptapify-dialog-col">-->
99
- <!-- <VSelect v-model="attrs.type" density="compact" variant="outlined" :label="t('dialog.video.src')" />-->
100
- <!-- </div>-->
475
+ <div class="tiptapify-video-dialog">
476
+ <VBtnToggle
477
+ v-if="showUpload"
478
+ v-model="form.source"
479
+ class="tiptapify-video-dialog__toggle"
480
+ mandatory
481
+ density="compact"
482
+ variant="outlined"
483
+ divided
484
+ >
485
+ <VBtn value="url" :prepend-icon="`mdiSvg:${mdi.mdiLinkVariant}`">
486
+ {{ t('dialog.video.source_url') }}
487
+ </VBtn>
488
+ <VBtn value="upload" :prepend-icon="`mdiSvg:${mdi.mdiUpload}`">
489
+ {{ t('dialog.video.source_upload') }}
490
+ </VBtn>
491
+ </VBtnToggle>
492
+
493
+ <template v-if="!isUpload">
494
+ <VTextField
495
+ v-model="form.url"
496
+ density="compact"
497
+ variant="outlined"
498
+ :label="t('dialog.video.url')"
499
+ :error-messages="urlError"
500
+ hide-details="auto"
501
+ autofocus
502
+ @blur="touched.url = true"
503
+ >
504
+ <template v-if="link" #append-inner>
505
+ <VChip size="small" color="primary" variant="tonal" label>
506
+ {{ link.label }}
507
+ </VChip>
508
+ </template>
509
+ </VTextField>
510
+
511
+ <div class="tiptapify-video-dialog__preview">
512
+ <video v-if="previewUrl && isFileLink" :src="previewUrl" controls preload="metadata" />
513
+ <iframe
514
+ v-else-if="previewUrl"
515
+ :src="previewUrl"
516
+ :title="t('dialog.video.preview')"
517
+ allow="autoplay; encrypted-media; fullscreen; picture-in-picture"
518
+ referrerpolicy="strict-origin-when-cross-origin"
519
+ allowfullscreen
520
+ />
521
+ <div v-else class="tiptapify-video-dialog__placeholder text-body-2">
522
+ <VIcon size="40" :icon="`mdiSvg:${mdi.mdiPlayBoxOutline}`" />
523
+ {{ t('dialog.video.preview_empty') }}
524
+ </div>
525
+ </div>
526
+ </template>
527
+
528
+ <template v-else>
529
+ <input ref="videoInput" type="file" accept="video/*" hidden @change="uploadVideo">
530
+
531
+ <div v-if="file.uploading || file.src" class="tiptapify-video-dialog__file">
532
+ <VIcon :icon="`mdiSvg:${mdi.mdiFileVideoOutline}`" />
533
+ <div class="tiptapify-video-dialog__file-info">
534
+ <div class="text-body-2 text-truncate">
535
+ {{ file.name }}
536
+ </div>
537
+ <template v-if="file.uploading">
538
+ <VProgressLinear :model-value="file.progress * 100" color="primary" height="4" rounded />
539
+ <div class="text-caption text-medium-emphasis">
540
+ {{ progressLabel }}
541
+ </div>
542
+ </template>
543
+ </div>
544
+ <VBtn v-if="file.uploading" size="small" variant="text" @click="cancelVideoUpload">
545
+ {{ t('dialog.cancel') }}
546
+ </VBtn>
547
+ <VBtn v-else-if="canUpload" size="small" variant="text" @click="videoInput?.click()">
548
+ {{ t('dialog.video.upload_replace') }}
549
+ </VBtn>
550
+ </div>
551
+
552
+ <VBtn
553
+ v-else
554
+ class="tiptapify-video-dialog__pick"
555
+ variant="outlined"
556
+ :prepend-icon="`mdiSvg:${mdi.mdiUpload}`"
557
+ @click="videoInput?.click()"
558
+ >
559
+ {{ t('dialog.video.upload_choose') }}
560
+ </VBtn>
561
+
562
+ <div v-if="file.error" class="text-error text-body-2" role="alert">
563
+ {{ file.error }}
564
+ </div>
565
+ </template>
566
+
567
+ <template v-if="hasFileOptions">
568
+ <div class="tiptapify-video-dialog__group" role="group" :aria-label="t('dialog.video.poster')">
569
+ <div class="text-body-2 text-medium-emphasis">
570
+ {{ t('dialog.video.poster') }}
571
+ </div>
572
+ <div class="tiptapify-video-dialog__poster">
573
+ <input ref="posterInput" type="file" accept="image/*" hidden @change="uploadPoster">
574
+ <button
575
+ type="button"
576
+ class="tiptapify-video-dialog__poster-tile"
577
+ :disabled="poster.uploading"
578
+ :aria-label="t('dialog.video.poster_choose')"
579
+ :style="form.poster ? { backgroundImage: `url(${JSON.stringify(form.poster)})` } : undefined"
580
+ @click="posterInput?.click()"
581
+ >
582
+ <VProgressCircular v-if="poster.uploading" indeterminate size="20" width="2" />
583
+ <template v-else-if="!form.poster">
584
+ <VIcon size="20" :icon="`mdiSvg:${mdi.mdiImagePlusOutline}`" />
585
+ <span class="text-caption">{{ t('dialog.video.poster_choose') }}</span>
586
+ </template>
587
+ </button>
588
+ <VTextField
589
+ v-model="form.poster"
590
+ density="compact"
591
+ variant="outlined"
592
+ :label="t('dialog.video.poster_url')"
593
+ :hint="t('dialog.video.poster_hint')"
594
+ :error-messages="poster.error"
595
+ hide-details="auto"
596
+ clearable
597
+ />
598
+ </div>
599
+ </div>
101
600
 
102
- <div class="tiptapify-dialog-col">
103
- <VTextField v-model="attrs.src" density="compact" variant="outlined" :label="t('dialog.video.src')" />
601
+ <div class="tiptapify-video-dialog__options">
602
+ <VCheckbox v-model="form.controls" :label="t('dialog.video.controls')" density="compact" hide-details />
603
+ <VCheckbox v-model="form.autoplay" :label="t('dialog.video.autoplay')" density="compact" hide-details />
604
+ <VCheckbox v-model="form.loop" :label="t('dialog.video.loop')" density="compact" hide-details />
605
+ </div>
606
+ </template>
607
+
608
+ <div class="tiptapify-video-dialog__row">
609
+ <div v-if="supportsStart" class="tiptapify-video-dialog__group tiptapify-video-dialog__start">
610
+ <label class="text-body-2 text-medium-emphasis" :for="startId">
611
+ {{ t('dialog.video.start') }}
612
+ </label>
613
+ <VTextField
614
+ :id="startId"
615
+ v-model="form.start"
616
+ density="compact"
617
+ variant="outlined"
618
+ placeholder="0:00"
619
+ :error-messages="startError"
620
+ hide-details="auto"
621
+ @blur="touched.start = true"
622
+ />
623
+ </div>
624
+
625
+ <div class="tiptapify-video-dialog__group" role="group" :aria-label="t('dialog.video.size')">
626
+ <div class="text-body-2 text-medium-emphasis">
627
+ {{ t('dialog.video.size') }}
628
+ </div>
629
+ <VBtnToggle
630
+ :model-value="form.sizeMode"
631
+ class="tiptapify-video-dialog__toggle"
632
+ mandatory
633
+ density="compact"
634
+ variant="outlined"
635
+ divided
636
+ @update:model-value="selectSizeMode"
637
+ >
638
+ <VBtn v-for="mode in sizeModes" :key="mode.value" :value="mode.value">
639
+ {{ mode.label }}
640
+ </VBtn>
641
+ </VBtnToggle>
642
+ </div>
104
643
  </div>
105
644
 
106
- <div class="tiptapify-dialog-col tiptapify-dialog-col--3">
645
+ <div v-if="form.sizeMode === 'custom'" class="tiptapify-video-dialog__size">
107
646
  <VTextField
108
- v-model="attrs.width"
647
+ :model-value="form.width"
109
648
  type="number"
649
+ min="1"
110
650
  density="compact"
111
651
  variant="outlined"
112
- :precision="0"
113
- :min="1"
652
+ suffix="px"
114
653
  :label="t('dialog.video.width')"
654
+ :error-messages="widthError"
655
+ hide-details="auto"
656
+ @update:model-value="resize('width', $event)"
657
+ @blur="touched.size = true"
115
658
  />
116
- </div>
117
-
118
- <div class="tiptapify-dialog-col tiptapify-dialog-col--3">
659
+ <span class="text-medium-emphasis">×</span>
119
660
  <VTextField
120
- v-model="attrs.height"
661
+ :model-value="form.height"
121
662
  type="number"
663
+ min="1"
122
664
  density="compact"
123
665
  variant="outlined"
124
- :precision="0"
125
- :min="1"
666
+ suffix="px"
126
667
  :label="t('dialog.video.height')"
668
+ :error-messages="heightError"
669
+ hide-details="auto"
670
+ @update:model-value="resize('height', $event)"
671
+ @blur="touched.size = true"
127
672
  />
673
+ <VBtn
674
+ :color="keepRatio ? 'primary' : undefined"
675
+ :variant="variantBtn"
676
+ :aria-label="t('dialog.video.keep_ratio')"
677
+ :aria-pressed="keepRatio"
678
+ size="36"
679
+ icon
680
+ @click="toggleKeepRatio"
681
+ >
682
+ <VIcon size="20" :icon="keepRatio ? `mdiSvg:${mdi.mdiLock}` : `mdiSvg:${mdi.mdiLockOpenVariant}`" />
683
+ <Tooltip :label="t('dialog.video.keep_ratio')" />
684
+ </VBtn>
685
+ </div>
686
+
687
+ <div v-if="applyError" class="text-error text-body-2" role="alert">
688
+ {{ applyError }}
128
689
  </div>
129
690
  </div>
130
691
  </VCardText>
@@ -134,20 +695,148 @@ onUnmounted(() => {
134
695
  <VCardActions>
135
696
  <div class="tiptapify-dialog-actions">
136
697
  <div class="tiptapify-dialog-actions__start">
137
- <VBtn v-if="editor.isActive('image')" color="warning" :variant="variantBtn" :disabled="isDisabled" @click="clear">
138
- {{ t('dialog.clear') }}
698
+ <VBtn v-if="isEdit" color="error" :variant="variantBtn" @click="remove">
699
+ {{ t('dialog.remove') }}
139
700
  </VBtn>
140
701
  </div>
141
702
  <div class="tiptapify-dialog-actions__end">
142
703
  <VBtn :variant="variantBtn" @click="close">
143
- {{ t('dialog.close') }}
704
+ {{ t('dialog.cancel') }}
144
705
  </VBtn>
145
- <VBtn color="primary" :variant="variantBtn" :disabled="isDisabled" @click="apply">
146
- {{ t('dialog.apply') }}
706
+ <VBtn type="submit" color="primary" :variant="variantBtn" :disabled="!canSubmit">
707
+ {{ t(isEdit ? 'dialog.save' : 'dialog.insert') }}
147
708
  </VBtn>
148
709
  </div>
149
710
  </div>
150
711
  </VCardActions>
151
712
  </template>
152
713
  </TiptapifyDialog>
153
- </template>
714
+ </template>
715
+
716
+ <style lang="scss" scoped>
717
+ .tiptapify-video-dialog,
718
+ .tiptapify-video-dialog__group {
719
+ display: flex;
720
+ flex-direction: column;
721
+ min-width: 0;
722
+ }
723
+
724
+ .tiptapify-video-dialog {
725
+ gap: 16px;
726
+ padding-top: 8px;
727
+ }
728
+
729
+ .tiptapify-video-dialog__group {
730
+ gap: 8px;
731
+ }
732
+
733
+ .tiptapify-video-dialog__toggle {
734
+ align-self: flex-start;
735
+ max-width: 100%;
736
+ }
737
+
738
+ .tiptapify-video-dialog__preview {
739
+ overflow: hidden;
740
+ aspect-ratio: 16 / 9;
741
+ border-radius: 8px;
742
+ background: rgba(var(--v-theme-on-surface), 0.04);
743
+
744
+ iframe,
745
+ video {
746
+ display: block;
747
+ width: 100%;
748
+ height: 100%;
749
+ border: 0;
750
+ }
751
+ }
752
+
753
+ .tiptapify-video-dialog__placeholder {
754
+ display: flex;
755
+ flex-direction: column;
756
+ align-items: center;
757
+ justify-content: center;
758
+ gap: 8px;
759
+ height: 100%;
760
+ padding: 16px;
761
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
762
+ text-align: center;
763
+ }
764
+
765
+ .tiptapify-video-dialog__file {
766
+ display: flex;
767
+ align-items: center;
768
+ gap: 12px;
769
+ padding: 10px 12px;
770
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
771
+ border-radius: 8px;
772
+ }
773
+
774
+ .tiptapify-video-dialog__file-info {
775
+ display: flex;
776
+ flex: 1;
777
+ flex-direction: column;
778
+ gap: 4px;
779
+ min-width: 0;
780
+ }
781
+
782
+ .tiptapify-video-dialog__pick {
783
+ height: 64px !important;
784
+ border-style: dashed;
785
+ text-transform: none;
786
+ }
787
+
788
+ .tiptapify-video-dialog__poster {
789
+ display: flex;
790
+ align-items: flex-start;
791
+ gap: 12px;
792
+
793
+ > .v-text-field {
794
+ flex: 1;
795
+ min-width: 0;
796
+ }
797
+ }
798
+
799
+ .tiptapify-video-dialog__poster-tile {
800
+ display: flex;
801
+ flex: none;
802
+ flex-direction: column;
803
+ align-items: center;
804
+ justify-content: center;
805
+ gap: 2px;
806
+ width: 112px;
807
+ aspect-ratio: 16 / 9;
808
+ border: 1px dashed rgba(var(--v-border-color), var(--v-border-opacity));
809
+ border-radius: 8px;
810
+ background: rgba(var(--v-theme-on-surface), 0.04) center / cover no-repeat;
811
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
812
+ cursor: pointer;
813
+
814
+ &:disabled {
815
+ cursor: default;
816
+ }
817
+ }
818
+
819
+ .tiptapify-video-dialog__options {
820
+ display: flex;
821
+ flex-wrap: wrap;
822
+ gap: 0 16px;
823
+ }
824
+
825
+ .tiptapify-video-dialog__row {
826
+ display: flex;
827
+ flex-wrap: wrap;
828
+ align-items: flex-start;
829
+ gap: 16px;
830
+ }
831
+
832
+ .tiptapify-video-dialog__start {
833
+ flex: 0 0 120px;
834
+ }
835
+
836
+ .tiptapify-video-dialog__size {
837
+ display: grid;
838
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
839
+ align-items: center;
840
+ gap: 8px;
841
+ }
842
+ </style>