tiptapify 0.3.1 → 0.3.3

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 (68) hide show
  1. package/dist/tiptapify.css +1 -1
  2. package/dist/tiptapify.mjs +54124 -48070
  3. package/dist/tiptapify.umd.js +59 -59
  4. package/index.d.ts +1 -0
  5. package/package.json +1 -1
  6. package/src/components/Footer.vue +12 -2
  7. package/src/components/ImageBubbleMenu.vue +130 -0
  8. package/src/components/MenuBubble.vue +36 -21
  9. package/src/components/Tiptapify.vue +189 -72
  10. package/src/components/Toolbar/Index.test.ts +96 -0
  11. package/src/components/Toolbar/Index.vue +225 -14
  12. package/src/components/Toolbar/Items.test.ts +49 -3
  13. package/src/components/Toolbar/Items.vue +25 -26
  14. package/src/components/Toolbar/alignment.ts +5 -0
  15. package/src/components/UI/TiptapifyDialog.vue +78 -12
  16. package/src/components/editorExtensions.test.ts +10 -0
  17. package/src/components/editorExtensions.ts +34 -5
  18. package/src/components/index.ts +3 -2
  19. package/src/extensions/components/actions/redo/Button.vue +1 -1
  20. package/src/extensions/components/actions/undo/Button.vue +1 -1
  21. package/src/extensions/components/alignment/menu/Button.vue +68 -0
  22. package/src/extensions/components/list/bullet/Button.vue +16 -1
  23. package/src/extensions/components/list/numbered/Button.vue +19 -0
  24. package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
  25. package/src/extensions/components/list/styles/index.ts +76 -0
  26. package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
  27. package/src/extensions/components/media/image/ImageDialog.vue +1 -1
  28. package/src/extensions/components/media/image/caption.test.ts +122 -0
  29. package/src/extensions/components/media/image/caption.ts +268 -0
  30. package/src/extensions/components/media/image/slider.test.ts +106 -0
  31. package/src/extensions/components/media/image/slider.ts +87 -0
  32. package/src/extensions/components/style/heading/Button.vue +75 -35
  33. package/src/i18n/locales/ar.json +22 -0
  34. package/src/i18n/locales/bg.json +22 -0
  35. package/src/i18n/locales/ch.json +22 -0
  36. package/src/i18n/locales/cz.json +22 -0
  37. package/src/i18n/locales/de.json +22 -0
  38. package/src/i18n/locales/el.json +22 -0
  39. package/src/i18n/locales/en.json +23 -1
  40. package/src/i18n/locales/es.json +22 -0
  41. package/src/i18n/locales/et.json +22 -0
  42. package/src/i18n/locales/fi.json +22 -0
  43. package/src/i18n/locales/fr.json +22 -0
  44. package/src/i18n/locales/he.json +22 -0
  45. package/src/i18n/locales/hi.json +22 -0
  46. package/src/i18n/locales/hr.json +22 -0
  47. package/src/i18n/locales/hu.json +22 -0
  48. package/src/i18n/locales/id.json +22 -0
  49. package/src/i18n/locales/it.json +22 -0
  50. package/src/i18n/locales/ja.json +22 -0
  51. package/src/i18n/locales/ko.json +22 -0
  52. package/src/i18n/locales/la.json +22 -0
  53. package/src/i18n/locales/lt.json +22 -0
  54. package/src/i18n/locales/nl.json +22 -0
  55. package/src/i18n/locales/no.json +22 -0
  56. package/src/i18n/locales/pl.json +22 -0
  57. package/src/i18n/locales/pt-BR.json +22 -0
  58. package/src/i18n/locales/pt.json +22 -0
  59. package/src/i18n/locales/ro.json +22 -0
  60. package/src/i18n/locales/ru.json +23 -1
  61. package/src/i18n/locales/se.json +22 -0
  62. package/src/i18n/locales/sk.json +22 -0
  63. package/src/i18n/locales/sl.json +22 -0
  64. package/src/i18n/locales/sr.json +22 -0
  65. package/src/i18n/locales/th.json +22 -0
  66. package/src/i18n/locales/tr.json +22 -0
  67. package/src/i18n/locales/uk.json +23 -1
  68. package/src/i18n/locales/vi.json +22 -0
package/index.d.ts CHANGED
@@ -192,6 +192,7 @@ export {
192
192
  TIPTAPIFY_CONFIG_KEY,
193
193
  useTiptapifyConfig,
194
194
  TiptapEditor,
195
+ TiptapEditor as TipTapEditor,
195
196
  nodeViewProps,
196
197
  NodeViewWrapper,
197
198
  VueNodeViewRenderer,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tiptapify",
3
3
  "types": "./index.d.ts",
4
- "version": "0.3.1",
4
+ "version": "0.3.3",
5
5
  "description": "Tiptap3 editor with Vuetify3 menu implementation",
6
6
  "exports": {
7
7
  ".": {
@@ -57,16 +57,16 @@ function showFooter() {
57
57
  <VRow>
58
58
  <VCol class="d-flex align-center" :class="`justify-${alignment}`">
59
59
  <template v-if="limit > 0">
60
- <VTooltip :open-delay="1500">
60
+ <VTooltip :open-delay="200" location="top">
61
61
  <template #default>
62
62
  <span v-html="limitText" />
63
63
  </template>
64
64
  <template #activator="{ props }">
65
+ <span class="tiptapify-footer--limit" v-bind="props" :aria-label="limitText.replace('<br>', ': ')">
65
66
  <svg
66
67
  height="25"
67
68
  width="25"
68
69
  viewBox="0 0 20 20"
69
- v-bind="props"
70
70
  :class="{
71
71
  'character-count': true,
72
72
  'character-count--alert': percentage > 75,
@@ -86,6 +86,8 @@ function showFooter() {
86
86
  />
87
87
  <circle r="6" cx="10" cy="10" fill="white" />
88
88
  </svg>
89
+ <span class="tiptapify-footer--status-item">{{ Math.min(percentage, 999) }}%</span>
90
+ </span>
89
91
  </template>
90
92
  </VTooltip>
91
93
 
@@ -113,6 +115,14 @@ function showFooter() {
113
115
  color: #999;
114
116
  }
115
117
 
118
+ /* The limit ring with its percentage; the tooltip spells out the limit */
119
+ .tiptapify-footer--limit {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 4px;
123
+ cursor: help;
124
+ }
125
+
116
126
  .tiptapify-footer--divider:not(:last-child) {
117
127
  margin: 0 8px;
118
128
  }
@@ -0,0 +1,130 @@
1
+ <script setup lang="ts">
2
+ import * as mdi from '@mdi/js'
3
+ import { Editor } from '@tiptap/vue-3'
4
+ import { BubbleMenu } from '@tiptap/vue-3/menus'
5
+ import { NodeSelection } from '@tiptap/pm/state'
6
+ import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
7
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
+ import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
9
+ import { inject, Ref, ref } from 'vue'
10
+ import { ComposerTranslation } from 'vue-i18n'
11
+
12
+ // The toolbar on a selected image: float toggles (or, for a slide, its place in the slider), download, edit, removal
13
+ const editor = inject('tiptapifyEditor') as Ref<Editor>
14
+
15
+ // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
16
+ // box is there, as the menu reads its options only when it is created
17
+ const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
18
+
19
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
20
+
21
+ // Without a float the image stands centered on its own line; a second click on a float removes it
22
+ const alignments: Array<{ value: ImageAlign, icon: string, label: string }> = [
23
+ { value: 'left', icon: mdi.mdiFormatFloatLeft, label: 'media.image_toolbar.align_left' },
24
+ { value: 'right', icon: mdi.mdiFormatFloatRight, label: 'media.image_toolbar.align_right' },
25
+ ]
26
+
27
+ function isImageSelected({ state }: { state: Editor['state'] }): boolean {
28
+ return state.selection instanceof NodeSelection && state.selection.node.type.name === 'image'
29
+ }
30
+
31
+ function toggleAlign(align: ImageAlign) {
32
+ const current = editor.value.getAttributes('image').align ?? null
33
+ editor.value.chain().focus().updateAttributes('image', { align: current === align ? null : align }).run()
34
+ }
35
+
36
+ // A slide of a slider (extensions/components/media/image/slider.ts) moves among the slides instead of floating
37
+ function isSlide(): boolean {
38
+ if (!isImageSelected(editor.value)) {
39
+ return false
40
+ }
41
+
42
+ // The selection may sit in text while the menu hides, where the node at it is a text node
43
+ const dom = editor.value.view.nodeDOM(editor.value.state.selection.from)
44
+
45
+ return dom instanceof HTMLElement && dom.classList.contains('tiptapify-slide')
46
+ }
47
+
48
+ function download() {
49
+ const { src } = editor.value.getAttributes('image')
50
+ if (src) {
51
+ window.open(src, '_blank', 'noopener')
52
+ }
53
+ }
54
+
55
+ </script>
56
+
57
+ <template>
58
+ <BubbleMenu
59
+ v-if="editor && scrollTarget"
60
+ :editor="editor"
61
+ plugin-key="tiptapifyImageMenu"
62
+ :options="{ placement: 'top', offset: -52, flip: false, scrollTarget }"
63
+ :should-show="isImageSelected"
64
+ >
65
+ <div class="tiptapify-image-menu tiptapify-quiet-buttons">
66
+ <template v-if="isSlide()">
67
+ <VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(-1)" @click="editor.chain().focus().moveImage(-1).run()">
68
+ <Tooltip :label="t('media.image_toolbar.move_back')" />
69
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiArrowLeft}`" />
70
+ </VBtn>
71
+ <VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(1)" @click="editor.chain().focus().moveImage(1).run()">
72
+ <Tooltip :label="t('media.image_toolbar.move_forward')" />
73
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiArrowRight}`" />
74
+ </VBtn>
75
+ </template>
76
+ <template v-else>
77
+ <VBtn
78
+ v-for="alignment in alignments"
79
+ :key="alignment.label"
80
+ size="32"
81
+ variant="text"
82
+ color=""
83
+ :class="{ 'tiptapify-btn--active': (editor.getAttributes('image').align ?? null) === alignment.value }"
84
+ @click="toggleAlign(alignment.value)"
85
+ >
86
+ <Tooltip :label="t(alignment.label)" />
87
+ <BtnIcon :icon="`mdiSvg:${alignment.icon}`" />
88
+ </VBtn>
89
+ </template>
90
+
91
+ <div class="tiptapify-image-menu__divider" />
92
+
93
+ <VBtn size="32" variant="text" color="" @click="download">
94
+ <Tooltip :label="t('media.image_toolbar.download')" />
95
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiTrayArrowDown}`" />
96
+ </VBtn>
97
+ <VBtn size="32" variant="text" color="" @click="editor.commands.showTiptapifyImage()">
98
+ <Tooltip :label="t('media.image_toolbar.edit')" />
99
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiImageEditOutline}`" />
100
+ </VBtn>
101
+
102
+ <div class="tiptapify-image-menu__divider" />
103
+
104
+ <VBtn size="32" variant="text" color="" @click="editor.chain().focus().deleteSelection().run()">
105
+ <Tooltip :label="t('media.image_toolbar.delete')" />
106
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiTrashCanOutline}`" />
107
+ </VBtn>
108
+ </div>
109
+ </BubbleMenu>
110
+ </template>
111
+
112
+ <style scoped lang="scss">
113
+ .tiptapify-image-menu {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 2px;
117
+ padding: 4px;
118
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
119
+ border-radius: 10px;
120
+ background: rgb(var(--v-theme-surface));
121
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
122
+ }
123
+
124
+ .tiptapify-image-menu__divider {
125
+ width: 1px;
126
+ height: 20px;
127
+ margin: 0 4px;
128
+ background: rgba(var(--v-border-color), var(--v-border-opacity));
129
+ }
130
+ </style>
@@ -2,6 +2,7 @@
2
2
  import { Editor } from '@tiptap/vue-3'
3
3
  import { computed, inject, Ref, ref } from 'vue'
4
4
  import { BubbleMenu } from '@tiptap/vue-3/menus'
5
+ import { NodeSelection } from '@tiptap/pm/state'
5
6
  import * as mdi from '@mdi/js'
6
7
 
7
8
  defineProps({
@@ -11,6 +12,10 @@ defineProps({
11
12
 
12
13
  const editor = inject('tiptapifyEditor') as Ref<Editor>
13
14
 
15
+ // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
16
+ // box is there, as the menu reads its options only when it is created
17
+ const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
18
+
14
19
  const items = ref([
15
20
  {
16
21
  name: 'bold',
@@ -85,11 +90,16 @@ const items = ref([
85
90
 
86
91
  <template>
87
92
  <BubbleMenu
88
- v-if="editor"
93
+ v-if="editor && scrollTarget"
89
94
  :editor="editor"
90
- :options="{ placement: 'bottom' }"
91
- :should-show="({ editor, view, state, from, to }) => {
92
- if (editor.isActive('image') || editor.isActive('code') || editor.isActive('codeBlock')) {
95
+ :options="{ placement: 'bottom', scrollTarget }"
96
+ :should-show="({ editor, state, from, to }) => {
97
+ // A selected block (image, paywall, widget) is not text to format
98
+ if (state.selection instanceof NodeSelection) {
99
+ return false
100
+ }
101
+
102
+ if (editor.isActive('code') || editor.isActive('codeBlock')) {
93
103
  return false
94
104
  }
95
105
 
@@ -99,27 +109,32 @@ const items = ref([
99
109
  return (from !== to) && !isAllSelected
100
110
  }"
101
111
  >
102
- <div class="bubble-menu">
103
- <VCard>
104
- <VCardText class="pa-0">
105
- <VToolbar :theme="theme" density="compact" height="auto" class="p-0">
106
- <VToolbarItems>
107
- <VBtnGroup divided density="compact">
108
- <VBtn v-for="(item, key) in items" :key="key" v-bind="item.props" size="x-small" @click="item.click">
109
- <VIcon :icon="item.icon" size="20" />
110
- </VBtn>
111
- </VBtnGroup>
112
- </VToolbarItems>
113
- </VToolbar>
114
- </VCardText>
115
- </VCard>
112
+ <div class="tiptapify-bubble-menu tiptapify-quiet-buttons">
113
+ <VBtn
114
+ v-for="(item, key) in items"
115
+ :key="key"
116
+ size="32"
117
+ variant="text"
118
+ color=""
119
+ :disabled="item.props?.disabled"
120
+ :class="{ 'tiptapify-btn--active': item.props?.color === 'primary' }"
121
+ @click="item.click"
122
+ >
123
+ <VIcon :icon="item.icon" size="20" />
124
+ </VBtn>
116
125
  </div>
117
126
  </BubbleMenu>
118
127
  </template>
119
128
 
120
129
  <style scoped lang="scss">
121
- .bubble-menu {
122
- border-radius: 6px;
123
- box-shadow: 4px 4px 20px var(--dark-gray);
130
+ .tiptapify-bubble-menu {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 2px;
134
+ padding: 4px;
135
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
136
+ border-radius: 10px;
137
+ background: rgb(var(--v-theme-surface));
138
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
124
139
  }
125
140
  </style>
@@ -9,6 +9,7 @@ import { Editor, EditorContent } from '@tiptap/vue-3'
9
9
  import { TiptapifyAiOptions, TiptapifyAiResolvedOptions, TiptapifyEditor, TiptapifyFooterAlignment, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
10
10
  import MenuBubble from '@tiptapify/components/MenuBubble.vue'
11
11
  import MenuFloating from '@tiptapify/components/MenuFloating.vue'
12
+ import ImageBubbleMenu from '@tiptapify/components/ImageBubbleMenu.vue'
12
13
 
13
14
  import { useI18n } from 'vue-i18n'
14
15
 
@@ -49,6 +50,8 @@ const props = defineProps({
49
50
  loading: { type: Boolean, default() { return false } },
50
51
  loadingColor: { type: String, default() { return 'default' } },
51
52
  loadingHeight: { type: String, default() { return '1px' } },
53
+ // From this many images in a row the editor shows them as a slider grid; 0 turns it off
54
+ imageSlider: { type: Number, default() { return 0 } },
52
55
  })
53
56
 
54
57
  const emit = defineEmits(['update:modelValue', 'editor-ready', 'content-changed'])
@@ -135,9 +138,17 @@ const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
135
138
  getJSON: () => editorInstance.getJSON(),
136
139
  })
137
140
  },
141
+ {
142
+ captionPlaceholder: t('dialog.image.caption_placeholder'),
143
+ sliderMinImages: props.imageSlider,
144
+ sliderLabel: t('media.slider'),
145
+ },
138
146
  )
139
147
 
140
148
  provide('tiptapifyEditor', editor)
149
+
150
+ const scrollContainer = ref<HTMLElement | null>(null)
151
+ provide('tiptapifyScrollContainer', scrollContainer)
141
152
  provide('tiptapifyI18n', { t })
142
153
  provide('tiptapifyAi', tiptapifyAi)
143
154
  provide(TIPTAPIFY_CONFIG_KEY, {
@@ -163,7 +174,7 @@ onBeforeUnmount(() => {
163
174
 
164
175
  <template>
165
176
  <div :id="`tiptapify-editor-${editor?.instanceId}`">
166
- <div :class="`border border-t-0 rounded-t-${rounded} rounded-b-${rounded}`">
177
+ <div :class="`tiptapify-frame rounded-${rounded}`">
167
178
  <template v-if="toolbar">
168
179
  <Toolbar
169
180
  v-if="editor"
@@ -179,11 +190,13 @@ onBeforeUnmount(() => {
179
190
 
180
191
  <VProgressLinear v-model="loadingProgress" :color="loadingColor" :height="loadingHeight" :indeterminate="loading" />
181
192
 
182
- <div class="pa-2 tiptapify-container resizable" :style="computeResizableHeight" @click="onContainerClick">
193
+ <div ref="scrollContainer" class="pa-2 tiptapify-container resizable" :style="computeResizableHeight" @click="onContainerClick">
183
194
  <MenuFloating v-if="floatingMenu" :variant="variantBtn" :theme="currentTheme" />
184
195
 
185
196
  <MenuBubble v-if="bubbleMenu" :variant="variantBtn" :theme="currentTheme" />
186
197
 
198
+ <ImageBubbleMenu />
199
+
187
200
  <EditorContent :editor="editor" class="tiptapify-editor" />
188
201
  </div>
189
202
 
@@ -232,12 +245,21 @@ onBeforeUnmount(() => {
232
245
  --border: 1px solid var(--gray-2);
233
246
  }
234
247
 
248
+ .tiptapify-frame {
249
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
250
+ border-bottom-color: rgba(var(--v-border-color), calc(var(--v-border-opacity) * 1.6));
251
+ background: rgb(var(--v-theme-surface));
252
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
253
+ }
254
+
235
255
  .resizable {
236
256
  resize: vertical;
237
257
  overflow: auto;
238
258
  }
239
259
 
240
260
  .tiptapify-container {
261
+ /* The containing block of the bubble menus, so they scroll with the text they belong to */
262
+ position: relative;
241
263
  display: flex;
242
264
  flex-direction: column;
243
265
  }
@@ -281,6 +303,8 @@ onBeforeUnmount(() => {
281
303
 
282
304
  /* List styles */
283
305
  ul, ol {
306
+ /* Next to a floated image the markers stay out of it */
307
+ display: flow-root;
284
308
  padding: 0 1rem;
285
309
  margin: 1.25rem 1rem 1.25rem 0.4rem;
286
310
 
@@ -518,92 +542,185 @@ onBeforeUnmount(() => {
518
542
  display: block;
519
543
  }
520
544
 
521
- [data-node="image"].has-focus {
522
- [data-resize-handle] {
523
- position: absolute;
524
- background: rgba(0, 0, 0, 0.5);
525
- border: 1px solid rgba(255, 255, 255, 0.8);
526
- border-radius: 2px;
527
- z-index: 10;
545
+ /* Image with the caption under it (see extensions/components/media/image/caption.ts) */
546
+ .tiptapify-image {
547
+ --tiptapify-image-max-height: 400px;
528
548
 
529
- &:hover {
530
- background: rgba(0, 0, 0, 0.8);
531
- }
549
+ flex-direction: column;
550
+ gap: 4px;
551
+ width: fit-content;
552
+ max-width: 100%;
553
+ margin: 1rem 0;
532
554
 
533
- /* Corner handles */
534
- &[data-resize-handle='top-left'],
535
- &[data-resize-handle='top-right'],
536
- &[data-resize-handle='bottom-left'],
537
- &[data-resize-handle='bottom-right'] {
538
- width: 8px;
539
- height: 8px;
540
- }
555
+ /* Never wider than the text, never taller than the max height: big photos stay out of the way */
556
+ img {
557
+ max-width: 100%;
558
+ height: auto !important;
559
+ margin: 0;
560
+ border-radius: 4px;
561
+ background: rgba(var(--v-theme-on-surface), 0.04);
562
+ }
541
563
 
542
- &[data-resize-handle='top-left'] {
543
- top: -4px;
544
- left: -4px;
545
- cursor: nwse-resize;
546
- }
564
+ [data-resize-wrapper] {
565
+ max-width: min(100%, calc(var(--tiptapify-image-max-height) * var(--tiptapify-image-ratio, 100)));
566
+ border-radius: 4px;
567
+ outline: 2px solid transparent;
568
+ outline-offset: 2px;
569
+ transition: outline-color 0.15s;
570
+ }
547
571
 
548
- &[data-resize-handle='top-right'] {
549
- top: -4px;
550
- right: -4px;
551
- cursor: nesw-resize;
552
- }
572
+ /* One block: hovering outlines it, a click selects it and shows its toolbar */
573
+ img {
574
+ cursor: pointer;
575
+ }
553
576
 
554
- &[data-resize-handle='bottom-left'] {
555
- bottom: -4px;
556
- left: -4px;
557
- cursor: nesw-resize;
558
- }
577
+ &:hover [data-resize-wrapper] {
578
+ outline-color: rgba(var(--v-theme-primary), 0.35);
579
+ }
559
580
 
560
- &[data-resize-handle='bottom-right'] {
561
- bottom: -4px;
562
- right: -4px;
563
- cursor: nwse-resize;
564
- }
581
+ &.ProseMirror-selectednode [data-resize-wrapper] {
582
+ outline-color: rgb(var(--v-theme-primary));
583
+ }
565
584
 
566
- /* Edge handles */
567
- &[data-resize-handle='top'],
568
- &[data-resize-handle='bottom'] {
569
- height: 6px;
570
- left: 8px;
571
- right: 8px;
572
- }
585
+ /* Centered unless floated; a floated image lets the text wrap around it */
586
+ &:not([data-align]):not(.tiptapify-slide) {
587
+ align-items: center;
588
+ margin-inline: auto;
573
589
 
574
- &[data-resize-handle='top'] {
575
- top: -3px;
576
- cursor: ns-resize;
590
+ .tiptapify-image-caption {
591
+ text-align: center;
577
592
  }
593
+ }
578
594
 
579
- &[data-resize-handle='bottom'] {
580
- bottom: -3px;
581
- cursor: ns-resize;
582
- }
595
+ &[data-align='left'],
596
+ &[data-align='right'] {
597
+ max-width: 50%;
598
+ margin-top: 0.25rem;
599
+ }
583
600
 
584
- &[data-resize-handle='left'],
585
- &[data-resize-handle='right'] {
586
- width: 6px;
587
- top: 8px;
588
- bottom: 8px;
589
- }
601
+ &[data-align='left'] {
602
+ float: left;
603
+ margin-right: 1.25rem;
604
+ }
590
605
 
591
- &[data-resize-handle='left'] {
592
- left: -3px;
593
- cursor: ew-resize;
594
- }
606
+ &[data-align='right'] {
607
+ float: right;
608
+ margin-left: 1.25rem;
609
+ }
610
+ }
595
611
 
596
- &[data-resize-handle='right'] {
597
- right: -3px;
598
- cursor: ew-resize;
599
- }
612
+ /* Images in a row shown as a slider (extensions/components/media/image/slider.ts): a numbered grid */
613
+ .tiptapify-image.tiptapify-slide {
614
+ position: relative;
615
+ display: inline-flex !important;
616
+ box-sizing: border-box;
617
+ width: calc(100% / 3);
618
+ margin: 0;
619
+ padding: 8px 6px;
620
+ vertical-align: top;
621
+ background: rgba(var(--v-theme-primary), 0.06);
622
+
623
+ [data-resize-wrapper] {
624
+ width: 100%;
625
+ }
626
+
627
+ img {
628
+ width: 100% !important;
629
+ max-width: none;
630
+ aspect-ratio: 4 / 3;
631
+ object-fit: cover;
600
632
  }
601
633
 
602
- [data-resize-state='true'] [data-resize-wrapper] {
603
- outline: 1px solid rgba(0, 0, 0, 0.25);
604
- border-radius: 0.125rem;
634
+ [data-resize-handle] {
635
+ display: none !important;
636
+ }
637
+
638
+ &::before {
639
+ position: absolute;
640
+ top: 14px;
641
+ left: 12px;
642
+ z-index: 1;
643
+ padding: 0 6px;
644
+ border-radius: 4px;
645
+ background: rgba(0, 0, 0, 0.6);
646
+ color: #fff;
647
+ content: attr(data-slide-label);
648
+ font-size: 12px;
649
+ font-weight: 600;
650
+ line-height: 20px;
651
+ pointer-events: none;
652
+ }
653
+ }
654
+
655
+ &:has(.tiptapify-slide) {
656
+ container-type: inline-size;
657
+ }
658
+
659
+ /* The caret between two slides: a vertical line at the edge of the slide before it */
660
+ .tiptapify-slide + .ProseMirror-gapcursor:has(+ .tiptapify-slide) {
661
+ display: inline-block;
662
+
663
+ &::after {
664
+ top: 8px;
665
+ width: 0;
666
+ height: calc((100cqw / 3 - 12px) * 0.75);
667
+ border-top: 0;
668
+ border-left: 2px solid currentColor;
605
669
  }
606
670
  }
671
+
672
+ .tiptapify-image-caption {
673
+ width: 100%;
674
+ min-width: 0;
675
+ padding: 2px 0 3px;
676
+ border: 0;
677
+ border-bottom: 1px dashed transparent;
678
+ background: transparent;
679
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
680
+ font: inherit;
681
+ font-size: 0.875em;
682
+ outline: none;
683
+ transition: border-color 0.15s;
684
+
685
+ &::placeholder {
686
+ color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
687
+ }
688
+
689
+ &:hover:not(:disabled),
690
+ &:focus {
691
+ border-bottom-color: rgba(var(--v-border-color), 0.4);
692
+ }
693
+
694
+ &:disabled:placeholder-shown {
695
+ display: none;
696
+ }
697
+ }
698
+
699
+ /* Resize handles of a selected image: dots on its corners and edge middles */
700
+ [data-node='image'] [data-resize-handle] {
701
+ display: none;
702
+ }
703
+
704
+ [data-node='image'].ProseMirror-selectednode [data-resize-handle] {
705
+ position: absolute;
706
+ z-index: 10;
707
+ display: block;
708
+ width: 10px;
709
+ height: 10px;
710
+ border: 2px solid rgb(var(--v-theme-surface));
711
+ border-radius: 50%;
712
+ background: rgb(var(--v-theme-primary));
713
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
714
+
715
+ &[data-resize-handle='top-left'] { top: -5px !important; left: -5px !important; cursor: nwse-resize; }
716
+ &[data-resize-handle='top-right'] { top: -5px !important; right: -5px !important; left: auto !important; cursor: nesw-resize; }
717
+ &[data-resize-handle='bottom-left'] { bottom: -5px !important; left: -5px !important; top: auto !important; cursor: nesw-resize; }
718
+ &[data-resize-handle='bottom-right'] { right: -5px !important; bottom: -5px !important; top: auto !important; left: auto !important; cursor: nwse-resize; }
719
+ &[data-resize-handle='top'] { top: -5px !important; left: calc(50% - 5px) !important; right: auto !important; cursor: ns-resize; }
720
+ &[data-resize-handle='bottom'] { bottom: -5px !important; top: auto !important; left: calc(50% - 5px) !important; right: auto !important; cursor: ns-resize; }
721
+ &[data-resize-handle='left'] { left: -5px !important; top: calc(50% - 5px) !important; bottom: auto !important; cursor: ew-resize; }
722
+ &[data-resize-handle='right'] { right: -5px !important; left: auto !important; top: calc(50% - 5px) !important; bottom: auto !important; cursor: ew-resize; }
723
+ }
607
724
  }
608
725
 
609
726
  /*
@@ -622,7 +739,7 @@ onBeforeUnmount(() => {
622
739
  */
623
740
  .tiptapify-dialog-grid {
624
741
  display: grid;
625
- gap: 24px;
742
+ gap: 16px;
626
743
  grid-template-columns: repeat(12, minmax(0, 1fr));
627
744
 
628
745
  > .tiptapify-dialog-col {