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,172 +1,467 @@
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'
6
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
5
7
  import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
6
-
7
- import type { iframeOptions } from '@tiptapify/types/iframe'
8
-
9
- import { computed, inject, Ref, ref, useTemplateRef, watch } from 'vue'
8
+ import {
9
+ IframeAttrs,
10
+ IframeSizeMode,
11
+ parsedFields,
12
+ parseEmbedInput,
13
+ ParsedIframe,
14
+ ratioPresets,
15
+ sizeFromAttrs,
16
+ sizeToAttrs,
17
+ srcError,
18
+ } from '@tiptapify/extensions/components/media/iframe/embed'
19
+ import { MediaSize, ratioOf, resizeSide, toSize } from '@tiptapify/utils/mediaSize'
20
+
21
+ import { computed, inject, onMounted, onUnmounted, reactive, Ref, ref, useTemplateRef, watch } from 'vue'
10
22
  import { ComposerTranslation } from 'vue-i18n'
11
23
 
12
- const { variantBtn, variantField } = useTiptapifyConfig()
24
+ const { variantBtn } = useTiptapifyConfig()
13
25
 
14
26
  const editor = inject('tiptapifyEditor') as Ref<Editor>
15
27
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
16
28
 
17
- const generateIframeAttrs = (): iframeOptions => ({
18
- src: '',
19
- name: '',
29
+ const dialog = useTemplateRef('dialog')
30
+
31
+ // The preview runs the embedded page, but keeps it from navigating the editor's page
32
+ const previewSandbox = 'allow-scripts allow-same-origin allow-popups allow-presentation allow-forms'
33
+
34
+ const generateForm = () => ({
35
+ input: '',
20
36
  title: '',
21
- height: null,
22
- width: null,
23
- frameborder: 0,
37
+ sizeMode: 'responsive' as IframeSizeMode,
38
+ width: null as number | string | null,
39
+ height: null as number | string | null,
40
+ name: '',
41
+ allow: '',
42
+ sandbox: '',
43
+ lazy: true,
44
+ border: false,
45
+ allowfullscreen: true,
24
46
  })
25
47
 
26
- const attrs = ref(generateIframeAttrs())
27
- const srcInvalid = ref(false)
48
+ const form = ref(generateForm())
49
+ // The size the embed code or the edited iframe has: the proportions of the responsive size
50
+ const known = ref<MediaSize>({ width: null, height: null })
51
+ // Field errors of a typed address show once the field was left or the form submitted, not while typing
52
+ const touched = reactive({ input: false, size: false })
53
+ const advancedOpen = ref(false)
54
+ const applyError = ref('')
55
+ const edited = ref<IframeAttrs | null>(null)
28
56
 
29
- const dialog = useTemplateRef('dialog')
57
+ const isEdit = computed(() => edited.value !== null)
30
58
 
31
- const isDisabled = computed(() => {
32
- const { src } = attrs.value
33
- return !src
59
+ const embed = computed(() => parseEmbedInput(form.value.input))
60
+ const parsedIframe = computed(() => (embed.value.kind === 'code' ? embed.value.iframe : null))
61
+
62
+ const src = computed(() => {
63
+ if (embed.value.kind === 'url') {
64
+ return embed.value.src
65
+ }
66
+
67
+ return parsedIframe.value?.src ?? ''
34
68
  })
35
69
 
36
- function apply() {
37
- let { src, name, title, width, height, frameborder } = attrs.value
70
+ const isValidSrc = computed(() => embed.value.kind !== 'invalid_code' && srcError(src.value) === null)
38
71
 
39
- const options: iframeOptions = {
40
- src,
41
- name,
42
- title,
72
+ const inputError = computed(() => {
73
+ const { kind } = embed.value
74
+
75
+ if (kind === 'invalid_code') {
76
+ return t('dialog.iframe.code_invalid')
43
77
  }
44
78
 
45
- if (width) {
46
- options.width = width
79
+ // A pasted code is checked at once; a typed address once it is done
80
+ if (kind !== 'code' && !touched.input) {
81
+ return ''
47
82
  }
48
83
 
49
- if (height) {
50
- options.height = height
84
+ const error = srcError(src.value)
85
+
86
+ return error ? t(`dialog.iframe.src_${error}`) : ''
87
+ })
88
+
89
+ const parsedSummary = computed(() => {
90
+ if (!parsedIframe.value || !isValidSrc.value) {
91
+ return ''
51
92
  }
52
93
 
53
- if (frameborder) {
54
- options.frameborder = Number(frameborder)
94
+ const fields = parsedFields(parsedIframe.value).map(({ key, params }) => t(`dialog.iframe.${key}`, params ?? {}))
95
+
96
+ return t('dialog.iframe.code_parsed', { fields: fields.join(', ') })
97
+ })
98
+
99
+ const titleMissing = computed(() => form.value.title.trim() === '')
100
+
101
+ const sizeModes = computed(() => [
102
+ { value: 'responsive', label: t('dialog.iframe.size_responsive') },
103
+ ...ratioPresets.map(ratio => ({ value: ratio, label: ratio })),
104
+ { value: 'custom', label: t('dialog.iframe.size_custom') },
105
+ ])
106
+
107
+ const responsiveHint = computed(() => {
108
+ const { width, height } = known.value
109
+
110
+ return t('dialog.iframe.size_responsive_hint', { ratio: width && height ? `${width} × ${height}` : '16:9' })
111
+ })
112
+
113
+ const widthError = computed(() => (
114
+ touched.size && form.value.sizeMode === 'custom' && !toSize(form.value.width) ? t('dialog.iframe.size_invalid') : ''
115
+ ))
116
+
117
+ const heightError = computed(() => (
118
+ touched.size && form.value.sizeMode === 'custom' && !toSize(form.value.height) ? t('dialog.iframe.size_invalid') : ''
119
+ ))
120
+
121
+ // A pasted code fills the fields it has; it is read again only when it changes, not on every key
122
+ watch(() => JSON.stringify(parsedIframe.value), () => {
123
+ if (parsedIframe.value) {
124
+ fillFromCode(parsedIframe.value)
55
125
  }
126
+ })
56
127
 
57
- if (src) {
58
- editor.value.chain().focus().setIframe(options).run()
128
+ function fillFromCode(iframe: ParsedIframe) {
129
+ const { title, name, allow, sandbox, loading, border, width, height } = iframe
130
+
131
+ Object.assign(form.value, {
132
+ ...(title && { title }),
133
+ ...(name && { name }),
134
+ ...(allow && { allow }),
135
+ ...(sandbox !== undefined && { sandbox }),
136
+ ...(loading && { lazy: loading === 'lazy' }),
137
+ ...(border !== undefined && { border }),
138
+ allowfullscreen: !!iframe.allowfullscreen,
139
+ })
140
+
141
+ if (width && height) {
142
+ known.value = { width, height }
59
143
  }
60
144
 
61
- close()
145
+ if (name || allow || sandbox !== undefined) {
146
+ advancedOpen.value = true
147
+ }
62
148
  }
63
149
 
64
- function clear() {
65
- editor.value.commands.deleteSelection()
150
+ // The custom size keeps its ratio while locked: the ratio of the code's or the edited size, else 16:9
151
+ const keepRatio = ref(true)
152
+ const ratio = ref(16 / 9)
153
+
154
+ function selectSizeMode(mode: IframeSizeMode) {
155
+ form.value.sizeMode = mode
156
+
157
+ if (mode === 'custom' && !toSize(form.value.width)) {
158
+ const { width, height } = known.value.width && known.value.height ? known.value : { width: 640, height: 360 }
159
+
160
+ Object.assign(form.value, { width, height })
161
+ ratio.value = ratioOf({ width, height }, 16 / 9)
162
+ }
163
+ }
164
+
165
+ function resize(side: 'width' | 'height', value: unknown) {
166
+ const current = { width: toSize(form.value.width), height: toSize(form.value.height) }
167
+ Object.assign(form.value, resizeSide(current, side, value, keepRatio.value ? ratio.value : null))
168
+ }
169
+
170
+ function toggleKeepRatio() {
171
+ keepRatio.value = !keepRatio.value
172
+
173
+ if (keepRatio.value) {
174
+ ratio.value = ratioOf({ width: toSize(form.value.width), height: toSize(form.value.height) }, ratio.value)
175
+ }
176
+ }
177
+
178
+ function text(value: string): string | null {
179
+ return value.trim() || null
180
+ }
181
+
182
+ function iframeAttrs(): IframeAttrs | null {
183
+ Object.assign(touched, { input: true, size: true })
184
+
185
+ if (!isValidSrc.value || widthError.value || heightError.value) {
186
+ return null
187
+ }
188
+
189
+ const { title, name, allow, sandbox, lazy, border, allowfullscreen, sizeMode, width, height } = form.value
190
+
191
+ return {
192
+ src: src.value,
193
+ title: text(title),
194
+ name: text(name),
195
+ allow: text(allow),
196
+ sandbox: text(sandbox),
197
+ loading: lazy ? 'lazy' : null,
198
+ allowfullscreen,
199
+ border,
200
+ ...sizeToAttrs(sizeMode, { width: toSize(width), height: toSize(height) }, known.value),
201
+ }
202
+ }
203
+
204
+ function apply() {
205
+ applyError.value = ''
206
+
207
+ const attrs = iframeAttrs()
208
+
209
+ if (!attrs) {
210
+ return
211
+ }
212
+
213
+ const chain = editor.value.chain().focus()
214
+ const done = isEdit.value ? chain.updateAttributes('iframe', attrs).run() : chain.setIframe(attrs).run()
215
+
216
+ if (!done) {
217
+ applyError.value = t('dialog.iframe.apply_failed')
218
+
219
+ return
220
+ }
66
221
 
67
222
  close()
68
223
  }
69
224
 
70
- function close() {
71
- dialog.value.close()
225
+ // Enter submits; a pasted code keeps its line breaks
226
+ function submitOnEnter(event: KeyboardEvent) {
227
+ event.preventDefault()
228
+ ;(event.target as HTMLElement).closest('form')?.requestSubmit()
229
+ }
230
+
231
+ function remove() {
232
+ editor.value.chain().focus().deleteSelection().run()
72
233
 
73
- attrs.value = generateIframeAttrs()
234
+ close()
74
235
  }
75
236
 
76
- function open () {
77
- const iframe = editor.value.getAttributes('iframe')
237
+ function close() {
238
+ dialog.value?.close()
239
+ }
78
240
 
79
- attrs.value.src = iframe?.src
80
- attrs.value.name = iframe?.name
81
- attrs.value.title = iframe?.title
82
- attrs.value.width = iframe?.width
83
- attrs.value.height = iframe?.height
84
- attrs.value.frameborder = !!iframe?.frameborder
241
+ function reset() {
242
+ form.value = generateForm()
243
+ known.value = { width: null, height: null }
244
+ Object.assign(touched, { input: false, size: false })
245
+ advancedOpen.value = false
246
+ applyError.value = ''
247
+ edited.value = null
248
+ keepRatio.value = true
249
+ ratio.value = 16 / 9
250
+ }
85
251
 
86
- dialog.value.open()
252
+ function prefill(node: IframeAttrs) {
253
+ const size = sizeFromAttrs(node)
254
+
255
+ edited.value = node
256
+
257
+ Object.assign(form.value, {
258
+ input: String(node.src ?? ''),
259
+ title: node.title ?? '',
260
+ name: node.name ?? '',
261
+ allow: node.allow ?? '',
262
+ sandbox: node.sandbox ?? '',
263
+ lazy: node.loading === 'lazy',
264
+ border: !!node.border,
265
+ allowfullscreen: node.allowfullscreen !== false,
266
+ sizeMode: size.mode,
267
+ width: size.width,
268
+ height: size.height,
269
+ })
270
+ known.value = size.width && size.height ? { width: size.width, height: size.height } : { width: null, height: null }
271
+ ratio.value = ratioOf(size, 16 / 9)
272
+ advancedOpen.value = !!(node.name || node.allow || node.sandbox !== null)
87
273
  }
88
274
 
89
- defineExpose({ open })
275
+ const showIframe = (event: CustomEvent) => {
276
+ if (event.detail.editorId !== editor.value.instanceId) {
277
+ return
278
+ }
279
+
280
+ reset()
90
281
 
91
- watch(() => attrs.value.src, () => {
92
- const azAZ09 = 'a-zA-Z0-9'
282
+ if (event.detail.node) {
283
+ prefill(event.detail.node)
284
+ }
93
285
 
94
- const regexHrefProto = 'https?:\\/\\/'
95
- const regexHrefDomain = `[${azAZ09}]+(\\.[${azAZ09}]+(-?[${azAZ09}]+)?)+`
96
- const regexHrefPath = `(\\/[${azAZ09}\\-]+)*`
97
- const regexHrefFragment = '(#[^\\s]*)?'
98
- const regexHrefQueryParam = `(\\?[${azAZ09}\\-_]+((\\[[${azAZ09}]+\\])?=[${azAZ09}\\-_%]+)?)?`
99
- const regexHrefQueryParamExtra = `(&[${azAZ09}\\-_]+((\\[[${azAZ09}]+\\])?=[${azAZ09}\\-_%]+)?)*`
100
- const regexHref = `${regexHrefProto}${regexHrefDomain}${regexHrefPath}${regexHrefFragment}${regexHrefQueryParam}${regexHrefQueryParamExtra}`
286
+ dialog.value?.open()
287
+ }
101
288
 
102
- const regex = new RegExp(`^(${regexHref})$`, 'i')
289
+ onMounted(() => {
290
+ window.addEventListener('tiptapify-show-iframe', showIframe as EventListener)
291
+ })
103
292
 
104
- srcInvalid.value = (attrs.value.src || '') !== '' && !regex.test(attrs.value.src)
293
+ onUnmounted(() => {
294
+ window.removeEventListener('tiptapify-show-iframe', showIframe as EventListener)
105
295
  })
106
296
  </script>
107
297
 
108
298
  <template>
109
- <TiptapifyDialog ref="dialog" module="iframe" :title="t('dialog.iframe.dialog_title')" :max-width="800">
299
+ <TiptapifyDialog
300
+ ref="dialog"
301
+ module="iframe"
302
+ :title="t(isEdit ? 'dialog.iframe.title_edit' : 'dialog.iframe.title_insert')"
303
+ :max-width="600"
304
+ @submit="apply"
305
+ @closed="reset"
306
+ >
110
307
  <template #content>
111
308
  <VCardText>
112
- <div class="tiptapify-dialog-grid">
113
- <div class="tiptapify-dialog-col">
309
+ <div class="tiptapify-iframe-dialog">
310
+ <VTextarea
311
+ v-model="form.input"
312
+ class="tiptapify-iframe-dialog__input"
313
+ rows="2"
314
+ density="compact"
315
+ variant="outlined"
316
+ spellcheck="false"
317
+ :label="t('dialog.iframe.input')"
318
+ :hint="parsedSummary ? undefined : t('dialog.iframe.input_hint')"
319
+ :messages="parsedSummary ? [parsedSummary] : []"
320
+ :error-messages="inputError"
321
+ hide-details="auto"
322
+ autofocus
323
+ @blur="touched.input = true"
324
+ @keydown.enter.exact="submitOnEnter"
325
+ />
326
+
327
+ <div class="tiptapify-iframe-dialog__preview">
328
+ <iframe
329
+ v-if="isValidSrc"
330
+ :src="src"
331
+ :title="t('dialog.iframe.preview')"
332
+ :sandbox="previewSandbox"
333
+ referrerpolicy="strict-origin-when-cross-origin"
334
+ />
335
+ <div v-else class="tiptapify-iframe-dialog__placeholder text-body-2">
336
+ <VIcon size="40" :icon="`mdiSvg:${mdi.mdiApplicationOutline}`" />
337
+ {{ t('dialog.iframe.preview_empty') }}
338
+ </div>
339
+ </div>
340
+
341
+ <VTextField
342
+ v-model="form.title"
343
+ density="compact"
344
+ variant="outlined"
345
+ :label="t('dialog.iframe.title_text')"
346
+ :messages="titleMissing ? [t('dialog.iframe.title_missing')] : []"
347
+ hide-details="auto"
348
+ >
349
+ <template #message="{ message }">
350
+ <span class="text-warning">{{ message }}</span>
351
+ </template>
352
+ </VTextField>
353
+
354
+ <div class="tiptapify-iframe-dialog__group" role="group" :aria-label="t('dialog.iframe.size')">
355
+ <div class="text-body-2 text-medium-emphasis">
356
+ {{ t('dialog.iframe.size') }}
357
+ </div>
358
+ <VBtnToggle
359
+ :model-value="form.sizeMode"
360
+ class="tiptapify-iframe-dialog__toggle"
361
+ mandatory
362
+ density="compact"
363
+ variant="outlined"
364
+ divided
365
+ @update:model-value="selectSizeMode"
366
+ >
367
+ <VBtn v-for="mode in sizeModes" :key="mode.value" :value="mode.value">
368
+ {{ mode.label }}
369
+ </VBtn>
370
+ </VBtnToggle>
371
+ <div v-if="form.sizeMode === 'responsive'" class="text-caption text-medium-emphasis">
372
+ {{ responsiveHint }}
373
+ </div>
374
+ </div>
375
+
376
+ <div v-if="form.sizeMode === 'custom'" class="tiptapify-iframe-dialog__size">
377
+ <VTextField
378
+ :model-value="form.width"
379
+ type="number"
380
+ min="1"
381
+ density="compact"
382
+ variant="outlined"
383
+ suffix="px"
384
+ :label="t('dialog.iframe.width')"
385
+ :error-messages="widthError"
386
+ hide-details="auto"
387
+ @update:model-value="resize('width', $event)"
388
+ @blur="touched.size = true"
389
+ />
390
+ <span class="text-medium-emphasis">×</span>
114
391
  <VTextField
115
- v-model="attrs.src"
392
+ :model-value="form.height"
393
+ type="number"
394
+ min="1"
116
395
  density="compact"
117
396
  variant="outlined"
118
- :label="t('dialog.iframe.src')"
119
- :error-messages="srcInvalid ? t('dialog.iframe.src_invalid') : ''"
397
+ suffix="px"
398
+ :label="t('dialog.iframe.height')"
399
+ :error-messages="heightError"
400
+ hide-details="auto"
401
+ @update:model-value="resize('height', $event)"
402
+ @blur="touched.size = true"
120
403
  />
404
+ <VBtn
405
+ :color="keepRatio ? 'primary' : undefined"
406
+ :variant="variantBtn"
407
+ :aria-label="t('dialog.iframe.keep_ratio')"
408
+ :aria-pressed="keepRatio"
409
+ size="36"
410
+ icon
411
+ @click="toggleKeepRatio"
412
+ >
413
+ <VIcon size="20" :icon="keepRatio ? `mdiSvg:${mdi.mdiLock}` : `mdiSvg:${mdi.mdiLockOpenVariant}`" />
414
+ <Tooltip :label="t('dialog.iframe.keep_ratio')" />
415
+ </VBtn>
121
416
  </div>
122
417
 
123
- <div class="tiptapify-dialog-col tiptapify-dialog-col--6">
124
- <div class="tiptapify-dialog-grid">
125
- <div class="tiptapify-dialog-col">
126
- <VTextField v-model="attrs.name" density="compact" variant="outlined" :label="t('dialog.iframe.name')" />
127
- </div>
128
-
129
- <div class="tiptapify-dialog-col">
130
- <VTextField v-model="attrs.title" density="compact" variant="outlined" :label="t('dialog.iframe.title')" />
131
- </div>
132
- </div>
133
- </div>
418
+ <div>
419
+ <VBtn
420
+ variant="text"
421
+ size="small"
422
+ class="tiptapify-iframe-dialog__advanced-toggle"
423
+ :aria-expanded="advancedOpen"
424
+ :append-icon="`mdiSvg:${advancedOpen ? mdi.mdiChevronUp : mdi.mdiChevronDown}`"
425
+ @click="advancedOpen = !advancedOpen"
426
+ >
427
+ {{ t('dialog.iframe.advanced') }}
428
+ </VBtn>
134
429
 
135
- <div class="tiptapify-dialog-col tiptapify-dialog-col--6">
136
- <div class="tiptapify-dialog-grid">
137
- <div class="tiptapify-dialog-col tiptapify-dialog-col--6">
430
+ <VExpandTransition>
431
+ <div v-show="advancedOpen" class="tiptapify-iframe-dialog__advanced">
138
432
  <VTextField
139
- v-model="attrs.width"
140
- type="number"
433
+ v-model="form.name"
141
434
  density="compact"
142
435
  variant="outlined"
143
- :precision="0"
144
- :min="1"
145
- :label="t('dialog.iframe.width')"
436
+ :label="t('dialog.iframe.name')"
437
+ hide-details="auto"
146
438
  />
147
- </div>
148
-
149
- <div class="tiptapify-dialog-col tiptapify-dialog-col--6">
150
439
  <VTextField
151
- v-model="attrs.height"
152
- type="number"
440
+ v-model="form.allow"
153
441
  density="compact"
154
442
  variant="outlined"
155
- :precision="0"
156
- :min="1"
157
- :label="t('dialog.iframe.height')"
443
+ :label="t('dialog.iframe.allow')"
444
+ :hint="t('dialog.iframe.allow_hint')"
445
+ hide-details="auto"
158
446
  />
159
- </div>
160
-
161
- <div class="tiptapify-dialog-col">
162
- <VSwitch
163
- v-model="attrs.frameborder"
447
+ <VTextField
448
+ v-model="form.sandbox"
164
449
  density="compact"
165
- :hide-details="true"
166
- :label="t('dialog.iframe.frameborder')"
450
+ variant="outlined"
451
+ :label="t('dialog.iframe.sandbox')"
452
+ :hint="t('dialog.iframe.sandbox_hint')"
453
+ hide-details="auto"
167
454
  />
455
+ <div class="tiptapify-iframe-dialog__options">
456
+ <VCheckbox v-model="form.lazy" :label="t('dialog.iframe.lazy')" density="compact" hide-details />
457
+ <VCheckbox v-model="form.border" :label="t('dialog.iframe.border')" density="compact" hide-details />
458
+ </div>
168
459
  </div>
169
- </div>
460
+ </VExpandTransition>
461
+ </div>
462
+
463
+ <div v-if="applyError" class="text-error text-body-2" role="alert">
464
+ {{ applyError }}
170
465
  </div>
171
466
  </div>
172
467
  </VCardText>
@@ -176,20 +471,97 @@ watch(() => attrs.value.src, () => {
176
471
  <VCardActions>
177
472
  <div class="tiptapify-dialog-actions">
178
473
  <div class="tiptapify-dialog-actions__start">
179
- <VBtn v-if="editor.isActive('image')" color="warning" :variant="variantBtn" :disabled="isDisabled" @click="clear">
180
- {{ t('dialog.clear') }}
474
+ <VBtn v-if="isEdit" color="error" :variant="variantBtn" @click="remove">
475
+ {{ t('dialog.remove') }}
181
476
  </VBtn>
182
477
  </div>
183
478
  <div class="tiptapify-dialog-actions__end">
184
479
  <VBtn :variant="variantBtn" @click="close">
185
- {{ t('dialog.close') }}
480
+ {{ t('dialog.cancel') }}
186
481
  </VBtn>
187
- <VBtn color="primary" :variant="variantBtn" :disabled="isDisabled" @click="apply">
188
- {{ t('dialog.apply') }}
482
+ <VBtn type="submit" color="primary" :variant="variantBtn" :disabled="!form.input.trim()">
483
+ {{ t(isEdit ? 'dialog.save' : 'dialog.insert') }}
189
484
  </VBtn>
190
485
  </div>
191
486
  </div>
192
487
  </VCardActions>
193
488
  </template>
194
489
  </TiptapifyDialog>
195
- </template>
490
+ </template>
491
+
492
+ <style lang="scss" scoped>
493
+ .tiptapify-iframe-dialog,
494
+ .tiptapify-iframe-dialog__group,
495
+ .tiptapify-iframe-dialog__advanced {
496
+ display: flex;
497
+ flex-direction: column;
498
+ min-width: 0;
499
+ }
500
+
501
+ .tiptapify-iframe-dialog {
502
+ gap: 16px;
503
+ padding-top: 8px;
504
+ }
505
+
506
+ .tiptapify-iframe-dialog__group {
507
+ gap: 8px;
508
+ }
509
+
510
+ .tiptapify-iframe-dialog__input :deep(textarea) {
511
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
512
+ font-size: 0.8125rem;
513
+ }
514
+
515
+ .tiptapify-iframe-dialog__preview {
516
+ overflow: hidden;
517
+ aspect-ratio: 16 / 9;
518
+ border-radius: 8px;
519
+ background: rgba(var(--v-theme-on-surface), 0.04);
520
+
521
+ iframe {
522
+ display: block;
523
+ width: 100%;
524
+ height: 100%;
525
+ border: 0;
526
+ }
527
+ }
528
+
529
+ .tiptapify-iframe-dialog__placeholder {
530
+ display: flex;
531
+ flex-direction: column;
532
+ align-items: center;
533
+ justify-content: center;
534
+ gap: 8px;
535
+ height: 100%;
536
+ padding: 16px;
537
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
538
+ text-align: center;
539
+ }
540
+
541
+ .tiptapify-iframe-dialog__toggle {
542
+ align-self: flex-start;
543
+ max-width: 100%;
544
+ }
545
+
546
+ .tiptapify-iframe-dialog__size {
547
+ display: grid;
548
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
549
+ align-items: center;
550
+ gap: 8px;
551
+ }
552
+
553
+ .tiptapify-iframe-dialog__advanced-toggle {
554
+ text-transform: none;
555
+ }
556
+
557
+ .tiptapify-iframe-dialog__advanced {
558
+ gap: 16px;
559
+ padding-top: 12px;
560
+ }
561
+
562
+ .tiptapify-iframe-dialog__options {
563
+ display: flex;
564
+ flex-wrap: wrap;
565
+ gap: 0 16px;
566
+ }
567
+ </style>