tiptapify 0.3.5 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/dist/content.css +28 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +110770 -65623
  4. package/dist/tiptapify.umd.js +106 -89
  5. package/index.d.ts +37 -0
  6. package/package.json +3 -2
  7. package/src/components/ImageBubbleMenu.vue +5 -33
  8. package/src/components/MenuBubble.test.ts +120 -0
  9. package/src/components/MenuBubble.vue +124 -134
  10. package/src/components/MenuFloating.vue +48 -45
  11. package/src/components/Tiptapify.upload.test.ts +10 -0
  12. package/src/components/Tiptapify.vue +85 -45
  13. package/src/components/Toolbar/Index.vue +10 -0
  14. package/src/components/UI/BubbleRow.vue +41 -0
  15. package/src/components/UI/TiptapifyDialog.test.ts +37 -3
  16. package/src/components/UI/TiptapifyDialog.vue +15 -4
  17. package/src/components/editorExtensions.test.ts +9 -0
  18. package/src/components/editorExtensions.ts +21 -1
  19. package/src/composables/fakeUploadEndpoint.ts +65 -0
  20. package/src/composables/useTiptapifyConfig.ts +19 -0
  21. package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
  22. package/src/composables/useTiptapifyLinkPreview.ts +60 -0
  23. package/src/composables/useTiptapifyUpload.test.ts +96 -0
  24. package/src/composables/useTiptapifyUpload.ts +126 -0
  25. package/src/extensions/components/ai/Dialog.test.ts +335 -0
  26. package/src/extensions/components/ai/Dialog.vue +607 -310
  27. package/src/extensions/components/ai/backend.test.ts +1 -0
  28. package/src/extensions/components/ai/backend.ts +2 -1
  29. package/src/extensions/components/ai/context.test.ts +149 -0
  30. package/src/extensions/components/ai/context.ts +145 -0
  31. package/src/extensions/components/ai/diff.test.ts +65 -0
  32. package/src/extensions/components/ai/diff.ts +120 -0
  33. package/src/extensions/components/ai/errors.test.ts +45 -0
  34. package/src/extensions/components/ai/errors.ts +67 -0
  35. package/src/extensions/components/ai/result.test.ts +144 -0
  36. package/src/extensions/components/ai/result.ts +101 -0
  37. package/src/extensions/components/ai/thinking.test.ts +45 -0
  38. package/src/extensions/components/ai/thinking.ts +58 -0
  39. package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
  40. package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
  41. package/src/extensions/components/media/iframe/Button.vue +6 -7
  42. package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
  43. package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
  44. package/src/extensions/components/media/iframe/embed.test.ts +123 -0
  45. package/src/extensions/components/media/iframe/embed.ts +236 -0
  46. package/src/extensions/components/media/iframe/index.test.ts +123 -0
  47. package/src/extensions/components/media/iframe/index.ts +92 -10
  48. package/src/extensions/components/media/image/ImageDialog.vue +430 -132
  49. package/src/extensions/components/media/image/caption.test.ts +66 -1
  50. package/src/extensions/components/media/image/caption.ts +27 -1
  51. package/src/extensions/components/media/image/imageDialog.test.ts +210 -0
  52. package/src/extensions/components/media/image/imageDialog.ts +117 -0
  53. package/src/extensions/components/media/image/index.ts +2 -1
  54. package/src/extensions/components/media/link/Button.test.ts +96 -0
  55. package/src/extensions/components/media/link/Button.vue +6 -2
  56. package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
  57. package/src/extensions/components/media/link/LinkDialog.vue +687 -61
  58. package/src/extensions/components/media/link/index.ts +111 -6
  59. package/src/extensions/components/media/link/link.marks.test.ts +89 -0
  60. package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
  61. package/src/extensions/components/media/link/linkDialog.ts +154 -0
  62. package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
  63. package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
  64. package/src/extensions/components/media/linkCard/index.test.ts +146 -0
  65. package/src/extensions/components/media/linkCard/index.ts +177 -0
  66. package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
  67. package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
  68. package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
  69. package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
  70. package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
  71. package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
  72. package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
  73. package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
  74. package/src/extensions/components/media/video/Button.vue +2 -1
  75. package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
  76. package/src/extensions/components/media/video/VideoDialog.vue +748 -59
  77. package/src/extensions/components/media/video/index.test.ts +154 -0
  78. package/src/extensions/components/media/video/index.ts +70 -5
  79. package/src/extensions/components/media/video/providers.test.ts +148 -0
  80. package/src/extensions/components/media/video/providers.ts +241 -0
  81. package/src/extensions/components/media/video/videoFile.test.ts +98 -0
  82. package/src/extensions/components/media/video/videoFile.ts +142 -0
  83. package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
  84. package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
  85. package/src/extensions/components/style/StyleColor.test.ts +255 -0
  86. package/src/extensions/components/style/StyleColor.vue +358 -208
  87. package/src/extensions/components/style/color/Button.vue +1 -3
  88. package/src/extensions/components/style/color/colors.test.ts +191 -0
  89. package/src/extensions/components/style/color/colors.ts +168 -0
  90. package/src/extensions/components/style/highlight/Button.vue +1 -3
  91. package/src/i18n/locales/ar.json +226 -23
  92. package/src/i18n/locales/bg.json +225 -22
  93. package/src/i18n/locales/ch.json +226 -23
  94. package/src/i18n/locales/cz.json +226 -23
  95. package/src/i18n/locales/de.json +226 -23
  96. package/src/i18n/locales/el.json +225 -22
  97. package/src/i18n/locales/en.json +225 -22
  98. package/src/i18n/locales/es.json +226 -23
  99. package/src/i18n/locales/et.json +225 -22
  100. package/src/i18n/locales/fi.json +226 -23
  101. package/src/i18n/locales/fr.json +226 -23
  102. package/src/i18n/locales/he.json +225 -22
  103. package/src/i18n/locales/hi.json +225 -22
  104. package/src/i18n/locales/hr.json +225 -22
  105. package/src/i18n/locales/hu.json +226 -23
  106. package/src/i18n/locales/id.json +225 -22
  107. package/src/i18n/locales/it.json +226 -23
  108. package/src/i18n/locales/ja.json +226 -23
  109. package/src/i18n/locales/ko.json +226 -23
  110. package/src/i18n/locales/la.json +226 -23
  111. package/src/i18n/locales/lt.json +226 -23
  112. package/src/i18n/locales/nl.json +226 -23
  113. package/src/i18n/locales/no.json +227 -24
  114. package/src/i18n/locales/pl.json +226 -23
  115. package/src/i18n/locales/pt-BR.json +225 -22
  116. package/src/i18n/locales/pt.json +226 -23
  117. package/src/i18n/locales/ro.json +225 -22
  118. package/src/i18n/locales/ru.json +227 -24
  119. package/src/i18n/locales/se.json +226 -23
  120. package/src/i18n/locales/sk.json +225 -22
  121. package/src/i18n/locales/sl.json +225 -22
  122. package/src/i18n/locales/sr.json +225 -22
  123. package/src/i18n/locales/th.json +226 -23
  124. package/src/i18n/locales/tr.json +226 -23
  125. package/src/i18n/locales/uk.json +227 -24
  126. package/src/i18n/locales/vi.json +226 -23
  127. package/src/index.ts +10 -0
  128. package/src/skins/registry.test.ts +1 -1
  129. package/src/skins/tokens.reachability.test.ts +1 -0
  130. package/src/skins/tokens.ts +3 -3
  131. package/src/types/editor.ts +40 -1
  132. package/src/utils/contentStyles.test.ts +116 -0
  133. package/src/utils/contentStyles.ts +175 -0
  134. package/src/utils/mediaSize.test.ts +22 -0
  135. package/src/utils/mediaSize.ts +27 -0
  136. package/src/utils/validateHref.test.ts +47 -2
  137. package/src/utils/validateHref.ts +47 -7
@@ -5,101 +5,229 @@ import { Editor } from '@tiptap/vue-3'
5
5
  import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
6
6
  import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
7
7
  import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
-
9
- import { computed, inject, onMounted, onUnmounted, Ref, ref, useTemplateRef } from 'vue'
8
+ import { useTiptapifyUpload } from '@tiptapify/composables/useTiptapifyUpload'
9
+ import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
10
+ import {
11
+ applyImage,
12
+ ImageSize,
13
+ ImageSizeMode,
14
+ initialSizeMode,
15
+ loadNaturalSize,
16
+ NaturalSize,
17
+ removeImage,
18
+ resizeSide,
19
+ sizeForMode,
20
+ textColumnWidth,
21
+ toSize,
22
+ } from '@tiptapify/extensions/components/media/image/imageDialog'
23
+
24
+ import { computed, inject, onMounted, onUnmounted, Ref, ref, useTemplateRef, watch } from 'vue'
10
25
  import { ComposerTranslation } from 'vue-i18n'
11
26
 
12
- const { variantBtn, variantField } = useTiptapifyConfig()
27
+ // A centered image has no align
28
+ type ImagePosition = 'center' | ImageAlign
29
+
30
+ const { variantBtn } = useTiptapifyConfig()
31
+ // Without the upload prop a picked image is embedded as base64, so the Upload source is always there
32
+ const { canUploadImage: canUpload, uploadFile: storeFile } = useTiptapifyUpload()
13
33
 
14
34
  const editor = inject('tiptapifyEditor') as Ref<Editor>
15
35
  const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
16
36
 
17
- const generateImageAttrs = () => ({
37
+ const dialog = useTemplateRef('dialog')
38
+
39
+ const generateForm = () => ({
18
40
  src: '',
19
41
  alt: '',
20
- title: null,
21
- height: null,
22
- width: null
42
+ title: '',
43
+ position: 'center' as ImagePosition,
23
44
  })
24
45
 
25
- const attrs = ref(generateImageAttrs())
26
-
27
- const dialog = useTemplateRef('dialog')
28
-
29
- const isDisabled = computed(() => {
30
- const { src } = attrs.value
31
- return !src
32
- })
46
+ const form = ref(generateForm())
47
+ const isEdit = ref(false)
48
+ const source = ref<'url' | 'upload'>('url')
49
+ const applyError = ref('')
33
50
 
51
+ const sizeMode = ref<ImageSizeMode>('original')
52
+ const customSize = ref<ImageSize>({ width: null, height: null })
34
53
  const keepRatio = ref(true)
35
- const ratio: Ref<string> = ref('')
36
- function setRatio(force: boolean = false) {
37
- if (!keepRatio.value && !force) {
54
+ // The ratio of the custom size, kept for an image whose natural size is unknown
55
+ const customRatio = ref<number | null>(null)
56
+ const textWidth = ref<number | null>(null)
57
+
58
+ const naturalSize = ref<NaturalSize | null>(null)
59
+ const previewFailed = ref(false)
60
+ // Drops a natural size that loads after the source changed again or the dialog closed
61
+ let naturalSizeRequest = 0
62
+
63
+ const pickedFile = ref<File | null>(null)
64
+ const uploadProgress = ref(0)
65
+ const uploading = ref(false)
66
+ const uploadError = ref('')
67
+ let uploadController: AbortController | null = null
68
+
69
+ const hasSource = computed(() => form.value.src.trim() !== '')
70
+ const altMissing = computed(() => form.value.alt.trim() === '')
71
+ const ratio = computed(() => (naturalSize.value ? naturalSize.value.width / naturalSize.value.height : customRatio.value))
72
+ const size = computed(() => (sizeMode.value === 'custom'
73
+ ? customSize.value
74
+ : sizeForMode(sizeMode.value, naturalSize.value, textWidth.value)))
75
+
76
+ const sizeModes = computed(() => [
77
+ { value: 'original', label: t('dialog.image.size_original') },
78
+ { value: 'text', label: t('dialog.image.size_text') },
79
+ { value: 'custom', label: t('dialog.image.size_custom') },
80
+ ])
81
+
82
+ const positions = computed(() => [
83
+ { value: 'center', icon: mdi.mdiFormatFloatCenter, label: t('dialog.image.position_center') },
84
+ { value: 'left', icon: mdi.mdiFormatFloatLeft, label: t('dialog.image.position_left') },
85
+ { value: 'right', icon: mdi.mdiFormatFloatRight, label: t('dialog.image.position_right') },
86
+ ])
87
+
88
+ watch(() => form.value.src.trim(), async (src) => {
89
+ const request = ++naturalSizeRequest
90
+ naturalSize.value = null
91
+ previewFailed.value = false
92
+ if (!src) {
38
93
  return
39
94
  }
40
95
 
41
- if ((!ratio.value || force) && attrs.value.width && attrs.value.height) {
42
- ratio.value = parseFloat(attrs.value.width / attrs.value.height).toFixed(4)
43
- }
44
- }
45
- function updateSizeRatio(dim: string) {
46
- if (!ratio.value || !attrs.value.width || !attrs.value.height) {
96
+ const loaded = await loadNaturalSize(src)
97
+ if (request !== naturalSizeRequest) {
47
98
  return
48
99
  }
49
100
 
50
- if (!keepRatio.value) {
51
- setRatio(true)
52
- return
53
- }
101
+ naturalSize.value = loaded
102
+ previewFailed.value = loaded === null
103
+ })
54
104
 
55
- const source = dim.toLowerCase()
56
- const sizeRatio = source === 'width'
57
- ? (attrs.value.width / ratio.value)
58
- : (attrs.value.height * ratio.value)
105
+ // Custom starts from the size the image had in the mode left
106
+ function selectSizeMode(mode: ImageSizeMode) {
107
+ if (mode === 'custom' && sizeMode.value !== 'custom' && size.value.width) {
108
+ customSize.value = { ...size.value }
109
+ customRatio.value = size.value.height ? size.value.width / size.value.height : null
110
+ }
59
111
 
60
- const target = source === 'width' ? 'height' : 'width'
112
+ sizeMode.value = mode
113
+ }
61
114
 
62
- attrs.value[target] = parseFloat(String(sizeRatio)).toFixed(0)
115
+ function resize(side: keyof ImageSize, value: unknown) {
116
+ customSize.value = resizeSide(customSize.value, side, value, keepRatio.value ? ratio.value : null)
63
117
  }
64
118
 
65
- function apply() {
66
- let { src, alt, title, width, height } = attrs.value
119
+ function toggleKeepRatio() {
120
+ keepRatio.value = !keepRatio.value
67
121
 
68
- const imageOptions: { src: string, alt: string, title?: string, width?: number, height?: number} = {
69
- src,
70
- alt
122
+ const { width, height } = customSize.value
123
+ if (keepRatio.value && width && height) {
124
+ customRatio.value = width / height
71
125
  }
126
+ }
127
+
128
+ async function uploadFile(picked: File | File[] | null) {
129
+ cancelUpload()
72
130
 
73
- if (title) {
74
- imageOptions.title = title
131
+ const file = (Array.isArray(picked) ? picked[0] : picked) ?? null
132
+ pickedFile.value = file
133
+ uploadError.value = ''
134
+ if (!file) {
135
+ return
75
136
  }
76
137
 
77
- if (width) {
78
- imageOptions.width = width
138
+ const controller = new AbortController()
139
+ uploadController = controller
140
+ uploading.value = true
141
+ uploadProgress.value = 0
142
+
143
+ try {
144
+ const { url } = await storeFile(file, {
145
+ kind: 'image',
146
+ onProgress: (fraction: number) => {
147
+ if (uploadController === controller) {
148
+ uploadProgress.value = fraction
149
+ }
150
+ },
151
+ signal: controller.signal,
152
+ })
153
+ if (!controller.signal.aborted) {
154
+ form.value.src = url
155
+ }
156
+ } catch (error) {
157
+ // A cancelled upload is not an error; the host's own message tells best what went wrong
158
+ if (!controller.signal.aborted) {
159
+ uploadError.value = error instanceof Error && error.message ? error.message : t('dialog.image.upload_error')
160
+ }
161
+ } finally {
162
+ if (uploadController === controller) {
163
+ uploadController = null
164
+ uploading.value = false
165
+ }
79
166
  }
167
+ }
80
168
 
81
- if (height) {
82
- imageOptions.height = height
169
+ function cancelUpload() {
170
+ uploadController?.abort()
171
+ uploadController = null
172
+ uploading.value = false
173
+ }
174
+
175
+ function apply() {
176
+ // Enter in a field submits even while the submit button is disabled
177
+ if (!hasSource.value) {
178
+ return
83
179
  }
84
180
 
85
- if (src) {
86
- editor.value.chain().focus().setImage(imageOptions).run()
181
+ const { src, alt, title, position } = form.value
182
+ const applied = applyImage(editor.value, {
183
+ src,
184
+ alt,
185
+ title,
186
+ width: size.value.width,
187
+ height: size.value.height,
188
+ align: position === 'center' ? null : position,
189
+ })
190
+ if (!applied) {
191
+ applyError.value = t('dialog.image.apply_error')
192
+
193
+ return
87
194
  }
88
195
 
89
196
  close()
90
197
  }
91
198
 
92
- function clear() {
93
- editor.value.commands.deleteSelection()
199
+ function remove() {
200
+ if (!removeImage(editor.value)) {
201
+ applyError.value = t('dialog.image.remove_error')
202
+
203
+ return
204
+ }
94
205
 
95
206
  close()
96
207
  }
97
208
 
98
209
  function close() {
99
210
  dialog.value?.close()
211
+ }
100
212
 
101
- attrs.value = generateImageAttrs()
102
- ratio.value = null
213
+ // The dialog closed in any way: its form starts over and a running upload stops
214
+ function reset() {
215
+ cancelUpload()
216
+ naturalSizeRequest++
217
+
218
+ form.value = generateForm()
219
+ isEdit.value = false
220
+ source.value = 'url'
221
+ applyError.value = ''
222
+ sizeMode.value = 'original'
223
+ customSize.value = { width: null, height: null }
224
+ keepRatio.value = true
225
+ customRatio.value = null
226
+ naturalSize.value = null
227
+ previewFailed.value = false
228
+ pickedFile.value = null
229
+ uploadProgress.value = 0
230
+ uploadError.value = ''
103
231
  }
104
232
 
105
233
  const showTiptapifyImage = (event: CustomEvent) => {
@@ -107,11 +235,21 @@ const showTiptapifyImage = (event: CustomEvent) => {
107
235
  return
108
236
  }
109
237
 
110
- attrs.value.src = event.detail.image?.src
111
- attrs.value.alt = event.detail.image?.alt
112
- attrs.value.title = event.detail.image?.title
113
- attrs.value.width = event.detail.image?.width
114
- attrs.value.height = event.detail.image?.height
238
+ const image: Record<string, unknown> | null = event.detail.image
239
+ const width = toSize(image?.width)
240
+ const height = toSize(image?.height)
241
+
242
+ isEdit.value = image !== null
243
+ form.value = {
244
+ src: String(image?.src ?? ''),
245
+ alt: String(image?.alt ?? ''),
246
+ title: String(image?.title ?? ''),
247
+ position: (image?.align as ImageAlign | null) ?? 'center',
248
+ }
249
+ sizeMode.value = initialSizeMode(image)
250
+ customSize.value = { width, height }
251
+ customRatio.value = width && height ? width / height : null
252
+ textWidth.value = textColumnWidth(editor.value.view.dom)
115
253
 
116
254
  dialog.value?.open()
117
255
  }
@@ -122,73 +260,182 @@ onMounted(() => {
122
260
 
123
261
  onUnmounted(() => {
124
262
  window.removeEventListener('tiptapify-show-tiptapifyImage', showTiptapifyImage as EventListener)
263
+ cancelUpload()
125
264
  })
126
265
  </script>
127
266
 
128
267
  <template>
129
- <TiptapifyDialog ref="dialog" module="image" :max-width="800">
268
+ <TiptapifyDialog
269
+ ref="dialog"
270
+ module="image"
271
+ :title="t(isEdit ? 'dialog.image.title_edit' : 'dialog.image.title_insert')"
272
+ :max-width="720"
273
+ @submit="apply"
274
+ @closed="reset"
275
+ >
130
276
  <template #content>
131
277
  <VCardText>
132
- <div class="tiptapify-grid-row">
133
- <div>
134
- <VTextField v-model="attrs.src" density="comfortable" :label="t('dialog.image.src')" />
278
+ <div class="tiptapify-image-dialog">
279
+ <div class="tiptapify-image-dialog__aside">
280
+ <div class="tiptapify-image-dialog__preview">
281
+ <img v-if="hasSource && !previewFailed" :src="form.src" alt="">
282
+ <VIcon v-else size="48" :icon="`mdiSvg:${mdi.mdiImageOutline}`" />
283
+ </div>
284
+ <div v-if="naturalSize" class="tiptapify-image-dialog__natural text-caption text-medium-emphasis">
285
+ {{ t('dialog.image.original_size', naturalSize) }}
286
+ </div>
135
287
  </div>
136
- </div>
137
288
 
138
- <div class="tiptapify-grid-row">
139
- <div>
289
+ <div class="tiptapify-image-dialog__fields">
290
+ <VBtnToggle
291
+ v-if="canUpload"
292
+ v-model="source"
293
+ class="tiptapify-image-dialog__toggle"
294
+ mandatory
295
+ density="compact"
296
+ variant="outlined"
297
+ divided
298
+ >
299
+ <VBtn value="url" :prepend-icon="`mdiSvg:${mdi.mdiLinkVariant}`">
300
+ {{ t('dialog.image.source_url') }}
301
+ </VBtn>
302
+ <VBtn value="upload" :prepend-icon="`mdiSvg:${mdi.mdiUpload}`">
303
+ {{ t('dialog.image.source_upload') }}
304
+ </VBtn>
305
+ </VBtnToggle>
306
+
307
+ <div v-if="canUpload && source === 'upload'" class="tiptapify-image-dialog__upload">
308
+ <VFileInput
309
+ :model-value="pickedFile"
310
+ accept="image/*"
311
+ density="compact"
312
+ variant="outlined"
313
+ :label="t('dialog.image.upload_file')"
314
+ prepend-icon=""
315
+ :prepend-inner-icon="`mdiSvg:${mdi.mdiPaperclip}`"
316
+ :error-messages="uploadError"
317
+ hide-details="auto"
318
+ @update:model-value="uploadFile"
319
+ />
320
+ <div v-if="uploading" class="tiptapify-image-dialog__progress">
321
+ <VProgressLinear :model-value="uploadProgress * 100" color="primary" height="4" rounded />
322
+ <VBtn size="small" variant="text" @click="cancelUpload">
323
+ {{ t('dialog.image.upload_cancel') }}
324
+ </VBtn>
325
+ </div>
326
+ </div>
140
327
  <VTextField
141
- v-model="attrs.alt"
142
- density="comfortable"
143
- :label="t('dialog.image.alt')"
328
+ v-else
329
+ v-model="form.src"
330
+ density="compact"
331
+ variant="outlined"
332
+ :label="t('dialog.image.src')"
333
+ hide-details="auto"
334
+ autofocus
144
335
  />
145
- </div>
146
- </div>
147
336
 
148
- <div class="tiptapify-grid-row">
149
- <div>
150
337
  <VTextField
151
- v-model="attrs.title"
152
- density="comfortable"
153
- :label="t('dialog.image.caption')"
154
- />
155
- </div>
156
- </div>
338
+ v-model="form.alt"
339
+ density="compact"
340
+ variant="outlined"
341
+ :label="t('dialog.image.alt_text')"
342
+ :messages="altMissing ? [t('dialog.image.alt_missing')] : []"
343
+ hide-details="auto"
344
+ >
345
+ <template #message="{ message }">
346
+ <span class="text-warning">{{ message }}</span>
347
+ </template>
348
+ </VTextField>
157
349
 
158
- <div class="tiptapify-grid-row">
159
- <div class="tiptapify-image-inputs-container">
160
350
  <VTextField
161
- v-model="attrs.width"
162
- type="number"
163
- density="comfortable"
164
- :precision="0"
165
- :min="1"
166
- :label="t('dialog.image.width')"
167
- @change="setRatio"
168
- @update:model-value="updateSizeRatio('width')"
169
- />
170
- <VTextField
171
- v-model="attrs.height"
172
- type="number"
173
- density="comfortable"
174
- :precision="0"
175
- :min="1"
176
- :label="t('dialog.image.height')"
177
- @change="setRatio"
178
- @update:model-value="updateSizeRatio('height')"
351
+ v-model="form.title"
352
+ density="compact"
353
+ variant="outlined"
354
+ :label="t('dialog.image.caption')"
355
+ hide-details="auto"
179
356
  />
180
- </div>
181
- <div>
182
- <VBtn
183
- v-model="keepRatio"
184
- :color="keepRatio ? 'primary' : 'secondary'"
185
- size="48"
186
- :variant="variantBtn"
187
- @click="keepRatio = !keepRatio"
188
- >
189
- <VIcon :icon="keepRatio ? `mdiSvg:${mdi.mdiLock}` : `mdiSvg:${mdi.mdiLockOpenVariant}`" />
190
- <Tooltip :label="t('dialog.image.keep_ratio')" />
191
- </VBtn>
357
+
358
+ <div class="tiptapify-image-dialog__group" role="group" :aria-label="t('dialog.image.size')">
359
+ <div class="text-body-2 text-medium-emphasis">
360
+ {{ t('dialog.image.size') }}
361
+ </div>
362
+ <VBtnToggle
363
+ :model-value="sizeMode"
364
+ class="tiptapify-image-dialog__toggle"
365
+ mandatory
366
+ density="compact"
367
+ variant="outlined"
368
+ divided
369
+ @update:model-value="selectSizeMode"
370
+ >
371
+ <VBtn v-for="mode in sizeModes" :key="mode.value" :value="mode.value">
372
+ {{ mode.label }}
373
+ </VBtn>
374
+ </VBtnToggle>
375
+
376
+ <div v-if="sizeMode === 'custom'" class="tiptapify-image-dialog__size">
377
+ <VTextField
378
+ :model-value="customSize.width"
379
+ type="number"
380
+ min="1"
381
+ density="compact"
382
+ variant="outlined"
383
+ :label="t('dialog.image.width')"
384
+ hide-details
385
+ @update:model-value="resize('width', $event)"
386
+ />
387
+ <span class="text-medium-emphasis">×</span>
388
+ <VTextField
389
+ :model-value="customSize.height"
390
+ type="number"
391
+ min="1"
392
+ density="compact"
393
+ variant="outlined"
394
+ :label="t('dialog.image.height')"
395
+ hide-details
396
+ @update:model-value="resize('height', $event)"
397
+ />
398
+ <VBtn
399
+ :color="keepRatio ? 'primary' : undefined"
400
+ :variant="variantBtn"
401
+ :aria-label="t('dialog.image.keep_ratio')"
402
+ :aria-pressed="keepRatio"
403
+ size="36"
404
+ icon
405
+ @click="toggleKeepRatio"
406
+ >
407
+ <VIcon size="20" :icon="keepRatio ? `mdiSvg:${mdi.mdiLock}` : `mdiSvg:${mdi.mdiLockOpenVariant}`" />
408
+ <Tooltip :label="t('dialog.image.keep_ratio')" />
409
+ </VBtn>
410
+ </div>
411
+ </div>
412
+
413
+ <div class="tiptapify-image-dialog__group" role="group" :aria-label="t('dialog.image.position')">
414
+ <div class="text-body-2 text-medium-emphasis">
415
+ {{ t('dialog.image.position') }}
416
+ </div>
417
+ <VBtnToggle
418
+ v-model="form.position"
419
+ class="tiptapify-image-dialog__toggle"
420
+ mandatory
421
+ density="compact"
422
+ variant="outlined"
423
+ divided
424
+ >
425
+ <VBtn
426
+ v-for="position in positions"
427
+ :key="position.value"
428
+ :value="position.value"
429
+ :prepend-icon="`mdiSvg:${position.icon}`"
430
+ >
431
+ {{ position.label }}
432
+ </VBtn>
433
+ </VBtnToggle>
434
+ </div>
435
+
436
+ <div v-if="applyError" class="text-error text-body-2" role="alert">
437
+ {{ applyError }}
438
+ </div>
192
439
  </div>
193
440
  </div>
194
441
  </VCardText>
@@ -196,42 +443,93 @@ onUnmounted(() => {
196
443
 
197
444
  <template #actions>
198
445
  <VCardActions>
199
- <VRow>
200
- <VCol class="d-flex justify-start">
201
- <VBtn v-if="editor.isActive('image')" color="warning" :variant="variantBtn" :disabled="isDisabled" @click="clear">
202
- {{ t('dialog.clear') }}
446
+ <div class="tiptapify-dialog-actions">
447
+ <div class="tiptapify-dialog-actions__start">
448
+ <VBtn v-if="isEdit" color="error" :variant="variantBtn" @click="remove">
449
+ {{ t('dialog.remove') }}
203
450
  </VBtn>
204
- </VCol>
205
- <VCol class="d-flex justify-end">
206
- <VBtn :variant="variantBtn" class="mr-2" @click="close">
207
- {{ t('dialog.close') }}
451
+ </div>
452
+ <div class="tiptapify-dialog-actions__end">
453
+ <VBtn :variant="variantBtn" @click="close">
454
+ {{ t('dialog.cancel') }}
208
455
  </VBtn>
209
- <VBtn color="primary" :variant="variantBtn" :disabled="isDisabled" @click="apply">
210
- {{ t('dialog.apply') }}
456
+ <VBtn type="submit" color="primary" :variant="variantBtn" :disabled="!hasSource">
457
+ {{ t(isEdit ? 'dialog.save' : 'dialog.insert') }}
211
458
  </VBtn>
212
- </VCol>
213
- </VRow>
459
+ </div>
460
+ </div>
214
461
  </VCardActions>
215
462
  </template>
216
463
  </TiptapifyDialog>
217
464
  </template>
218
465
 
219
466
  <style lang="scss" scoped>
220
- .tiptapify-grid-row {
221
- margin-top: 8px;
222
- gap: 16px;
467
+ .tiptapify-image-dialog {
223
468
  display: grid;
224
- grid-template-columns: 9fr 1fr;
225
- justify-items: end;
469
+ grid-template-columns: 180px minmax(0, 1fr);
470
+ gap: 24px;
471
+ padding-top: 8px;
226
472
 
227
- :nth-child(1) {
228
- width: 100%;
473
+ @media (max-width: 600px) {
474
+ grid-template-columns: minmax(0, 1fr);
229
475
  }
476
+ }
230
477
 
231
- .tiptapify-image-inputs-container {
232
- display: grid;
233
- grid-template-columns: 1fr 1fr;
234
- gap: 16px;
478
+ .tiptapify-image-dialog__preview {
479
+ display: flex;
480
+ align-items: center;
481
+ justify-content: center;
482
+ height: 180px;
483
+ overflow: hidden;
484
+ border-radius: 8px;
485
+ background: rgba(var(--v-theme-on-surface), 0.04);
486
+ color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
487
+
488
+ img {
489
+ width: 100%;
490
+ height: 100%;
491
+ object-fit: contain;
235
492
  }
236
493
  }
237
- </style>
494
+
495
+ .tiptapify-image-dialog__natural {
496
+ margin-top: 8px;
497
+ text-align: center;
498
+ }
499
+
500
+ .tiptapify-image-dialog__fields,
501
+ .tiptapify-image-dialog__upload,
502
+ .tiptapify-image-dialog__group {
503
+ display: flex;
504
+ flex-direction: column;
505
+ min-width: 0;
506
+ }
507
+
508
+ .tiptapify-image-dialog__fields {
509
+ gap: 16px;
510
+ }
511
+
512
+ .tiptapify-image-dialog__upload,
513
+ .tiptapify-image-dialog__group {
514
+ gap: 8px;
515
+ }
516
+
517
+ .tiptapify-image-dialog__toggle {
518
+ align-self: flex-start;
519
+ max-width: 100%;
520
+ }
521
+
522
+ .tiptapify-image-dialog__progress {
523
+ display: flex;
524
+ align-items: center;
525
+ gap: 8px;
526
+ }
527
+
528
+ .tiptapify-image-dialog__size {
529
+ display: grid;
530
+ grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
531
+ align-items: center;
532
+ gap: 8px;
533
+ margin-top: 8px;
534
+ }
535
+ </style>