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
@@ -3,56 +3,59 @@ import { Editor } from '@tiptap/vue-3'
3
3
  import { inject, Ref } from 'vue'
4
4
  import { FloatingMenu } from '@tiptap/vue-3/menus'
5
5
  import * as mdi from '@mdi/js'
6
-
7
- defineProps({
8
- variant: { type: String, default () { return '' } },
9
- theme: { type: String, default () { return 'light' } },
10
- })
6
+ import BubbleRow from '@tiptapify/components/UI/BubbleRow.vue'
7
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
+ import { ComposerTranslation } from 'vue-i18n'
11
9
 
12
10
  const editor = inject('tiptapifyEditor') as Ref<Editor>
11
+
12
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
13
+
14
+ // What an empty line may become
15
+ const items = [
16
+ {
17
+ name: 'h1',
18
+ icon: mdi.mdiFormatHeader1,
19
+ label: 'style.headings.h1',
20
+ hotkey: 'Mod-Alt-1',
21
+ active: () => editor.value.isActive('heading', { level: 1 }),
22
+ click: () => editor.value.chain().focus().toggleHeading({ level: 1 }).run(),
23
+ },
24
+ {
25
+ name: 'h2',
26
+ icon: mdi.mdiFormatHeader2,
27
+ label: 'style.headings.h2',
28
+ hotkey: 'Mod-Alt-2',
29
+ active: () => editor.value.isActive('heading', { level: 2 }),
30
+ click: () => editor.value.chain().focus().toggleHeading({ level: 2 }).run(),
31
+ },
32
+ {
33
+ name: 'bulletList',
34
+ icon: mdi.mdiFormatListBulleted,
35
+ label: 'lists.bullet',
36
+ hotkey: 'Mod-Shift-8',
37
+ active: () => editor.value.isActive('bulletList'),
38
+ click: () => editor.value.chain().focus().toggleBulletList().run(),
39
+ },
40
+ ]
13
41
  </script>
14
42
 
15
43
  <template>
16
44
  <FloatingMenu v-if="editor" :editor="editor">
17
- <div class="floating-menu">
18
- <VCard>
19
- <VCardText class="pa-0">
20
- <VToolbar :theme="theme" density="compact" height="auto" class="p-0">
21
- <VToolbarItems>
22
- <VBtnGroup divided density="compact">
23
- <VBtn
24
- :color="`${editor.isActive('heading', { level: 1 }) ? 'primary' : ''}`"
25
- size="small"
26
- @click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
27
- >
28
- <VIcon :icon="mdi['mdiFormatHeader1']" size="16" />
29
- </VBtn>
30
- <VBtn
31
- :color="`${editor.isActive('heading', { level: 2 }) ? 'primary' : ''}`"
32
- size="small"
33
- @click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
34
- >
35
- <VIcon :icon="mdi['mdiFormatHeader2']" size="16" />
36
- </VBtn>
37
- <VBtn
38
- :color="`${editor.isActive('bulletList') ? 'primary' : ''}`"
39
- size="small"
40
- @click="editor.chain().focus().toggleBulletList().run()"
41
- >
42
- <VIcon :icon="mdi['mdiFormatListBulleted']" size="16" />
43
- </VBtn>
44
- </VBtnGroup>
45
- </VToolbarItems>
46
- </VToolbar>
47
- </VCardText>
48
- </VCard>
49
- </div>
45
+ <BubbleRow class="floating-menu">
46
+ <VBtn
47
+ v-for="item in items"
48
+ :key="item.name"
49
+ size="32"
50
+ variant="text"
51
+ color=""
52
+ :class="{ 'tiptapify-btn--active': item.active() }"
53
+ :aria-pressed="item.active()"
54
+ @click="item.click"
55
+ >
56
+ <Tooltip :label="t(item.label)" :hotkey="item.hotkey" />
57
+ <VIcon :icon="`mdiSvg:${item.icon}`" size="20" />
58
+ </VBtn>
59
+ </BubbleRow>
50
60
  </FloatingMenu>
51
61
  </template>
52
-
53
- <style scoped lang="scss">
54
- .floating-menu {
55
- border-radius: 6px;
56
- box-shadow: 4px 4px 20px var(--dark-gray);
57
- }
58
- </style>
@@ -0,0 +1,10 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import Tiptapify from '@tiptapify/components/Tiptapify.vue'
3
+
4
+ describe('Tiptapify upload prop', () => {
5
+ it('defaults to no upload endpoint', () => {
6
+ const props = (Tiptapify as unknown as { props: Record<string, { default: unknown }> }).props
7
+
8
+ expect(props.upload.default).toBeNull()
9
+ })
10
+ })
@@ -11,23 +11,29 @@ import {
11
11
  TiptapifyAiResolvedOptions,
12
12
  TiptapifyEditor,
13
13
  TiptapifyFooterAlignment,
14
+ TiptapifyLinkPreviewOptions,
14
15
  TiptapifySkinInput,
16
+ TiptapifyUploadOptions,
15
17
  variantBtnTypes,
16
18
  variantFieldTypes
17
19
  } from '@tiptapify/types/editor'
18
20
  import MenuBubble from '@tiptapify/components/MenuBubble.vue'
19
21
  import MenuFloating from '@tiptapify/components/MenuFloating.vue'
20
22
  import ImageBubbleMenu from '@tiptapify/components/ImageBubbleMenu.vue'
23
+ import PasteCardMenu from '@tiptapify/extensions/components/media/linkCard/PasteCardMenu.vue'
21
24
 
22
25
  import { useI18n } from 'vue-i18n'
23
26
 
24
27
  import { getTiptapEditor } from '@tiptapify/components/index'
25
- import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
28
+ import { skinRootClasses, TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
29
+ import { TIPTAPIFY_UPLOAD_KEY } from '@tiptapify/composables/useTiptapifyUpload'
30
+ import { createLinkPreviewState, TIPTAPIFY_LINK_PREVIEW_KEY } from '@tiptapify/composables/useTiptapifyLinkPreview'
26
31
  import { resolveSkin } from '@tiptapify/skins/registry'
27
32
  import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
28
33
 
29
34
  import Footer from '@tiptapify/components/Footer.vue'
30
35
  import { useTheme } from 'vuetify/framework'
36
+ import { contentCss, contentHTML, TiptapifyContentStyles } from '@tiptapify/utils/contentStyles'
31
37
 
32
38
  const props = defineProps({
33
39
  locale: { type: String, default () { return 'en' } },
@@ -63,6 +69,14 @@ const props = defineProps({
63
69
  imageSlider: { type: Number, default() { return 0 } },
64
70
  // The skin of the editor chrome: a registered name ('default', 'pastel') or an inline skin object
65
71
  skin: { type: [String, Object] as PropType<TiptapifySkinInput>, default() { return defaults.skin } },
72
+ // The upload endpoint of files picked in the image and video dialogs; without it a picked image is
73
+ // embedded as base64 and a video is inserted by its link only
74
+ upload: { type: Object as PropType<TiptapifyUploadOptions | null>, default: null },
75
+ // The backend endpoint of link cards (OpenGraph data of pages); without it there are no link cards
76
+ linkPreview: { type: Object as PropType<TiptapifyLinkPreviewOptions | null>, default: null },
77
+ // How the saved HTML carries the look of its content: `inline` styles, or `classes` for a site that loads
78
+ // tiptapify/content.css
79
+ contentStyles: { type: String as PropType<TiptapifyContentStyles>, default: 'inline' },
66
80
  })
67
81
 
68
82
  const emit = defineEmits(['update:modelValue', 'editor-ready', 'content-changed'])
@@ -121,7 +135,7 @@ const tiptapifyAi = computed(() => {
121
135
  })
122
136
 
123
137
  function contentChanged() {
124
- const html = editor.value?.getHTML() ?? ''
138
+ const html = contentHTML(editor.value?.getHTML() ?? '', props.contentStyles)
125
139
  emit('update:modelValue', html)
126
140
  emit('content-changed', { html, json: editor.value?.getJSON() })
127
141
  }
@@ -145,7 +159,7 @@ const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
145
159
  (<TiptapifyEditor>editorInstance).interactiveStyles = props.interactiveStyles
146
160
  emit('editor-ready', {
147
161
  editor: editorInstance,
148
- getHTML: () => editorInstance.getHTML(),
162
+ getHTML: () => contentHTML(editorInstance.getHTML(), props.contentStyles),
149
163
  getJSON: () => editorInstance.getJSON(),
150
164
  })
151
165
  },
@@ -153,34 +167,33 @@ const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
153
167
  captionPlaceholder: t('dialog.image.caption_placeholder'),
154
168
  sliderMinImages: props.imageSlider,
155
169
  sliderLabel: t('media.slider'),
170
+ // Without an upload endpoint the image dialog embeds a picked file as base64
171
+ allowBase64: !props.upload?.endpoint,
156
172
  },
157
173
  )
158
174
 
159
175
  provide('tiptapifyEditor', editor)
160
176
 
177
+ // The editor shows its content with the content styles, under its own `.tiptap` (the HTML preview too)
178
+ const CONTENT_STYLE_ID = 'tiptapify-content-styles'
179
+ if (typeof document !== 'undefined' && !document.getElementById(CONTENT_STYLE_ID)) {
180
+ const style = document.createElement('style')
181
+ style.id = CONTENT_STYLE_ID
182
+ style.textContent = contentCss('.tiptap', { weightless: false })
183
+ document.head.append(style)
184
+ }
185
+
161
186
  const scrollContainer = ref<HTMLElement | null>(null)
162
187
  provide('tiptapifyScrollContainer', scrollContainer)
163
188
  provide('tiptapifyI18n', { t })
164
189
  provide('tiptapifyAi', tiptapifyAi)
190
+ provide(TIPTAPIFY_UPLOAD_KEY, toRef(props, 'upload'))
191
+ provide(TIPTAPIFY_LINK_PREVIEW_KEY, createLinkPreviewState(toRef(props, 'linkPreview')))
165
192
  // The skin prop resolves through the registry; computed refs so a prop change
166
193
  // re-resolves tokens and shape without an editor re-render
167
194
  const resolvedSkin = computed(() => resolveSkin(props.skin))
168
195
 
169
- // The shape classes: `.tiptapify-shape-{flag}-{value}` for all six flags, so CSS gates on
170
- // shape (not on the skin name); templates read the computed refs. The name class stays
171
- // as the escape hatch for consumer CSS.
172
- const shapeClasses = computed(() => {
173
- const shape = resolvedSkin.value.shape
174
-
175
- return [
176
- `tiptapify-shape-toolbar-${shape.toolbar}`,
177
- `tiptapify-shape-groups-${shape.groups}`,
178
- `tiptapify-shape-buttons-${shape.buttons}`,
179
- `tiptapify-shape-bubble-${shape.bubble}`,
180
- `tiptapify-shape-dialog-${shape.dialog}`,
181
- `tiptapify-shape-menu-${shape.menu}`,
182
- ].join(' ')
183
- })
196
+ const rootClasses = computed(() => skinRootClasses(resolvedSkin.value.className, resolvedSkin.value.shape))
184
197
 
185
198
  provide(TIPTAPIFY_CONFIG_KEY, {
186
199
  variantBtn: toRef(props, 'variantBtn'),
@@ -189,6 +202,7 @@ provide(TIPTAPIFY_CONFIG_KEY, {
189
202
  skinName: computed(() => resolvedSkin.value.name),
190
203
  tokens: computed(() => resolvedSkin.value.tokens),
191
204
  shape: computed(() => resolvedSkin.value.shape),
205
+ rootClasses,
192
206
  })
193
207
 
194
208
  editor.value?.chain().setFontFamily(props.defaultFontFamily).run()
@@ -210,7 +224,7 @@ onBeforeUnmount(() => {
210
224
  <template>
211
225
  <div
212
226
  :id="`tiptapify-editor-${editor?.instanceId}`"
213
- :class="[resolvedSkin.className, shapeClasses]"
227
+ :class="rootClasses"
214
228
  :style="resolvedSkin.tokens"
215
229
  >
216
230
  <div :class="`tiptapify-frame rounded-${rounded}`">
@@ -230,12 +244,14 @@ onBeforeUnmount(() => {
230
244
  <VProgressLinear v-model="loadingProgress" :color="loadingColor" :height="loadingHeight" :indeterminate="loading" />
231
245
 
232
246
  <div ref="scrollContainer" class="pa-2 tiptapify-container resizable" :style="computeResizableHeight" @click="onContainerClick">
233
- <MenuFloating v-if="floatingMenu" :variant="variantBtn" :theme="currentTheme" />
247
+ <MenuFloating v-if="floatingMenu" />
234
248
 
235
- <MenuBubble v-if="bubbleMenu" :variant="variantBtn" :theme="currentTheme" />
249
+ <MenuBubble v-if="bubbleMenu" />
236
250
 
237
251
  <ImageBubbleMenu />
238
252
 
253
+ <PasteCardMenu />
254
+
239
255
  <EditorContent :editor="editor" class="tiptapify-editor" />
240
256
  </div>
241
257
 
@@ -370,34 +386,15 @@ onBeforeUnmount(() => {
370
386
  }
371
387
  }
372
388
 
373
- /* Task list specific styles */
389
+ /* Task lists: the layout is the content styles (utils/contentStyles.ts); the editor adds the pointer */
374
390
  ul[data-type='taskList'] {
375
- list-style: none;
376
- margin-left: 0;
377
- padding: 0;
378
-
379
- li {
380
- align-items: flex-start;
381
- display: flex;
382
-
383
- > label {
384
- flex: 0 0 auto;
385
- margin-right: 0.5rem;
386
- user-select: none;
387
- }
388
-
389
- > div {
390
- flex: 1 1 auto;
391
- }
391
+ li > label {
392
+ user-select: none;
392
393
  }
393
394
 
394
395
  input[type='checkbox'] {
395
396
  cursor: pointer;
396
397
  }
397
-
398
- ul[data-type='taskList'] {
399
- margin: 0;
400
- }
401
398
  }
402
399
 
403
400
  /* Heading styles */
@@ -598,6 +595,41 @@ onBeforeUnmount(() => {
598
595
  display: block;
599
596
  }
600
597
 
598
+ /*
599
+ * While editing, a click on a player or an embed selects its block (a double click opens the video
600
+ * or the embed dialog)
601
+ */
602
+ &[contenteditable='true'] {
603
+ div[data-youtube-video],
604
+ .iframe-wrapper,
605
+ .tiptapify-video {
606
+ cursor: pointer;
607
+ }
608
+
609
+ div[data-youtube-video] iframe,
610
+ .iframe-wrapper iframe,
611
+ .tiptapify-video video {
612
+ pointer-events: none;
613
+ }
614
+ }
615
+
616
+ div[data-youtube-video].ProseMirror-selectednode iframe,
617
+ .iframe-wrapper.ProseMirror-selectednode iframe,
618
+ .tiptapify-video.ProseMirror-selectednode video {
619
+ outline: 2px solid rgb(var(--v-theme-primary));
620
+ outline-offset: 2px;
621
+ }
622
+
623
+ /* Link cards: their look is the content styles; the editor only adds the selection */
624
+ .tiptapify-link-card {
625
+ cursor: pointer;
626
+
627
+ &.ProseMirror-selectednode {
628
+ outline: 2px solid rgb(var(--v-theme-primary));
629
+ outline-offset: 2px;
630
+ }
631
+ }
632
+
601
633
  /* Image with the caption under it (see extensions/components/media/image/caption.ts) */
602
634
  .tiptapify-image {
603
635
  --tiptapify-image-max-height: 400px;
@@ -608,7 +640,10 @@ onBeforeUnmount(() => {
608
640
  max-width: 100%;
609
641
  margin: 1rem 0;
610
642
 
611
- /* Never wider than the text, never taller than the max height: big photos stay out of the way */
643
+ /*
644
+ * Never wider than the text; without a set width (dialog or resizing) never taller than the max height
645
+ * either, so big photos stay out of the way
646
+ */
612
647
  img {
613
648
  max-width: 100%;
614
649
  height: auto !important;
@@ -616,12 +651,16 @@ onBeforeUnmount(() => {
616
651
  }
617
652
 
618
653
  [data-resize-wrapper] {
619
- max-width: min(100%, calc(var(--tiptapify-image-max-height) * var(--tiptapify-image-ratio, 100)));
654
+ max-width: 100%;
620
655
  border-radius: var(--tf-image-radius, 0.125rem);
621
656
  outline: 1px solid transparent;
622
657
  transition: outline-color 0.15s;
623
658
  }
624
659
 
660
+ &:not([data-sized]) [data-resize-wrapper] {
661
+ max-width: min(100%, calc(var(--tiptapify-image-max-height) * var(--tiptapify-image-ratio, 100)));
662
+ }
663
+
625
664
  /* One block: hovering outlines it, a click selects it and shows its toolbar */
626
665
  img {
627
666
  cursor: pointer;
@@ -862,6 +901,7 @@ onBeforeUnmount(() => {
862
901
 
863
902
  .tiptapify-dialog-actions {
864
903
  display: flex;
904
+ flex: 1 1 auto;
865
905
  flex-wrap: wrap;
866
906
  align-items: center;
867
907
  justify-content: space-between;
@@ -366,6 +366,16 @@ toolbarItems = toolbarItems
366
366
  }
367
367
  }
368
368
 
369
+ /*
370
+ * Classic active buttons (`color="primary"` -> `.bg-primary`): Vuetify 4 puts its utilities in a cascade
371
+ * layer under the button variant's background, so this unlayered rule restores the fill. The quiet buttons
372
+ * above keep their own pressed look (their rules carry `!important`).
373
+ */
374
+ .tiptapify-toolbar .v-btn.bg-primary {
375
+ background-color: rgb(var(--v-theme-primary));
376
+ color: rgb(var(--v-theme-on-primary));
377
+ }
378
+
369
379
  /* A toolbar button that opens a dropdown: the current choice and a chevron */
370
380
  .tiptapify-toolbar .v-btn.tiptapify-dropdown-button {
371
381
  min-width: 0;
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
3
+
4
+ // The surface of the menus floating over the text (selection, empty line, image): one row of 32px icon
5
+ // buttons in groups split by `.tiptapify-bubble-divider`; an active button carries `.tiptapify-btn--active`
6
+ const { shape } = useTiptapifyConfig()
7
+ </script>
8
+
9
+ <template>
10
+ <div class="tiptapify-bubble-row" :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }">
11
+ <slot />
12
+ </div>
13
+ </template>
14
+
15
+ <style scoped lang="scss">
16
+ /* The fallbacks are the classic shape's base: the default skin emits no bubble tokens */
17
+ .tiptapify-bubble-row {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: var(--tf-bubble-gap, 2px);
21
+ padding: var(--tf-bubble-padding, 4px);
22
+ border: var(--tf-bubble-border, none);
23
+ border-radius: var(--tf-bubble-radius, 6px);
24
+ background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
25
+ box-shadow: var(--tf-bubble-shadow, 0 4px 14px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08));
26
+ }
27
+
28
+ :slotted(.tiptapify-bubble-divider) {
29
+ flex: none;
30
+ width: 1px;
31
+ height: 20px;
32
+ margin: 0 4px;
33
+ background: rgba(var(--v-border-color), var(--v-border-opacity));
34
+ }
35
+
36
+ /* The classic pressed look: a primary tint (the quiet shape paints its own, Toolbar/Index.vue) */
37
+ .tiptapify-bubble-row:not(.tiptapify-quiet-buttons) :slotted(.v-btn.tiptapify-btn--active) {
38
+ background-color: rgba(var(--v-theme-primary), 0.12);
39
+ color: rgb(var(--v-theme-primary));
40
+ }
41
+ </style>
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
  import { App, computed, createApp, h, nextTick, provide, Ref, ref } from 'vue'
3
3
  import { createVuetify } from 'vuetify'
4
4
  import { VTextField } from 'vuetify/components'
@@ -23,7 +23,7 @@ if (!('visualViewport' in globalThis)) {
23
23
  // variant and the field defaults all key off `shape.dialog`, not the skin name.
24
24
  const openApps: { app: App, host: HTMLElement }[] = []
25
25
 
26
- async function openDialog (skinInput: unknown): Promise<HTMLElement> {
26
+ async function openDialog (skinInput: unknown, listeners: Record<string, () => void> = {}, slots: Record<string, () => unknown> = {}): Promise<HTMLElement> {
27
27
  const config = resolveSkin(skinInput)
28
28
  const dialog: Ref<{ open: () => void } | null> = ref(null)
29
29
 
@@ -39,8 +39,9 @@ async function openDialog (skinInput: unknown): Promise<HTMLElement> {
39
39
  })
40
40
  provide('tiptapifyI18n', { t: (key: string) => key })
41
41
 
42
- return () => h(TiptapifyDialog, { ref: dialog, title: 'Probe dialog', module: 'link' }, {
42
+ return () => h(TiptapifyDialog, { ref: dialog, title: 'Probe dialog', module: 'link', ...listeners }, {
43
43
  content: () => h('div', { class: 'probe-content' }, [h(VTextField, { label: 'Field' })]),
44
+ ...slots,
44
45
  })
45
46
  },
46
47
  })
@@ -130,3 +131,36 @@ describe('TiptapifyDialog shape wiring', () => {
130
131
  expect(classic.querySelector('.tiptapify-dialog-header')?.nextElementSibling?.classList.contains('v-divider')).toBe(true)
131
132
  })
132
133
  })
134
+
135
+ describe('TiptapifyDialog form', () => {
136
+ it('submits on Enter in a field: the content is a form', async () => {
137
+ const onSubmit = vi.fn()
138
+ const card = await openDialog('default', { onSubmit })
139
+
140
+ card.querySelector('form')!.requestSubmit()
141
+
142
+ expect(onSubmit).toHaveBeenCalledOnce()
143
+ })
144
+
145
+ it('tells the dialog it closed, however it was closed', async () => {
146
+ const onClosed = vi.fn()
147
+ const card = await openDialog('default', { onClosed })
148
+
149
+ card.querySelector<HTMLElement>('.tiptapify-dialog-close')!.click()
150
+ await nextTick()
151
+
152
+ expect(onClosed).toHaveBeenCalledOnce()
153
+ })
154
+ })
155
+
156
+ describe('TiptapifyDialog title', () => {
157
+ it('shows the title prop, or the title slot of the dialog', async () => {
158
+ const plain = await openDialog('default')
159
+ expect(plain.querySelector('.tiptapify-dialog-title')!.textContent!.trim()).toBe('Probe dialog')
160
+
161
+ disposeDialogs()
162
+
163
+ const custom = await openDialog('default', {}, { title: () => h('b', { class: 'probe-title' }, 'With icon') })
164
+ expect(custom.querySelector('.tiptapify-dialog-title .probe-title')!.textContent).toBe('With icon')
165
+ })
166
+ })
@@ -23,10 +23,18 @@ const { shape, tokens } = useTiptapifyConfig()
23
23
  const dialogSoft = computed(() => shape.value.dialog === 'soft')
24
24
 
25
25
  defineExpose({ open, close })
26
- const emits = defineEmits(['closeDialog'])
26
+ // `submit`: Enter in a field or a submit button; `closed`: closed in any way (close button, click outside,
27
+ // Esc, close()), for the dialog to reset its form
28
+ const emits = defineEmits(['closeDialog', 'submit', 'closed'])
27
29
 
28
30
  const showDialog = ref<boolean>(false)
29
31
 
32
+ watch(showDialog, (shown) => {
33
+ if (!shown) {
34
+ emits('closed')
35
+ }
36
+ })
37
+
30
38
  const field = { variant: 'outlined', density: 'comfortable' }
31
39
  const fieldDefaults = {
32
40
  VTextField: field,
@@ -171,7 +179,8 @@ onBeforeUnmount(() => {
171
179
  style="user-select: none;"
172
180
  >
173
181
  <span class="tiptapify-dialog-title">
174
- {{ title ?? t(`dialog.${module}.title`) }}
182
+ <!-- A dialog may put an icon or more into its title -->
183
+ <slot name="title">{{ title ?? t(`dialog.${module}.title`) }}</slot>
175
184
  </span>
176
185
 
177
186
  <VSpacer />
@@ -193,9 +202,11 @@ onBeforeUnmount(() => {
193
202
 
194
203
  <!-- In the soft dialog shape, fields a dialog does not style itself get the light outlined look -->
195
204
  <VDefaultsProvider :defaults="dialogSoft ? fieldDefaults : {}">
196
- <slot name="content" />
205
+ <form class="tiptapify-dialog-form" novalidate @submit.prevent="emits('submit')">
206
+ <slot name="content" />
197
207
 
198
- <slot name="actions" />
208
+ <slot name="actions" />
209
+ </form>
199
210
  </VDefaultsProvider>
200
211
  </VCard>
201
212
  </VDialog>
@@ -7,4 +7,13 @@ describe('editor extensions', () => {
7
7
 
8
8
  expect(link?.options).toMatchObject({ openOnClick: false, enableClickSelection: true })
9
9
  })
10
+
11
+ it('keeps base64 images unless the host uploads images to its own server', () => {
12
+ const image = (allowBase64?: boolean) => editorExtensions('', false, [], null, { captionPlaceholder: '', sliderMinImages: 0, sliderLabel: '', allowBase64 })
13
+ .find(extension => extension.name === 'image')
14
+
15
+ expect(image()?.options.allowBase64).toBe(true)
16
+ expect(image(false)?.options.allowBase64).toBe(false)
17
+ })
18
+
10
19
  })
@@ -34,6 +34,9 @@ import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/c
34
34
  import { ImageSlider } from '@tiptapify/extensions/components/media/image/slider'
35
35
  import { TiptapifyLink } from '@tiptapify/extensions/components/media/link'
36
36
  import { TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
37
+ import { VideoFile } from '@tiptapify/extensions/components/media/video/videoFile'
38
+ import { LinkCard } from '@tiptapify/extensions/components/media/linkCard'
39
+ import { LinkCardPaste } from '@tiptapify/extensions/components/media/linkCard/pasteCard'
37
40
  import CodeBlockComponent from '@tiptapify/extensions/components/CodeBlockComponent.vue'
38
41
  import SlashCommands, { SlashCommandsExtensionOptions } from '@tiptapify/extensions/slash-commands'
39
42
  import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
@@ -59,10 +62,23 @@ export interface TiptapifyImageOptions {
59
62
  // Images in a row from which the site shows a slider; 0 disables the slider grid
60
63
  sliderMinImages: number
61
64
  sliderLabel: string
65
+ // Images embedded as base64 data URLs: kept unless the host uploads images to its own server
66
+ allowBase64?: boolean
62
67
  }
63
68
 
64
69
  const defaultImageOptions: TiptapifyImageOptions = { captionPlaceholder: '', sliderMinImages: 0, sliderLabel: '' }
65
70
 
71
+ // No default size: a responsive video is saved without one and must load back responsive
72
+ const ResponsiveYoutube = Youtube.extend({
73
+ addAttributes() {
74
+ return {
75
+ ...this.parent?.(),
76
+ width: { default: null },
77
+ height: { default: null },
78
+ }
79
+ },
80
+ })
81
+
66
82
  export function editorExtensions (
67
83
  placeholder: string,
68
84
  slashCommands: SlashCommandsConfig,
@@ -112,17 +128,21 @@ export function editorExtensions (
112
128
  directions: ['top', 'right', 'bottom', 'left', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
113
129
  },
114
130
  captionPlaceholder: imageOptions.captionPlaceholder,
131
+ allowBase64: imageOptions.allowBase64 ?? true,
115
132
  }),
116
133
  ImageSlider.configure({
117
134
  minImages: imageOptions.sliderMinImages,
118
135
  label: imageOptions.sliderLabel,
119
136
  }),
120
- Youtube.configure({
137
+ ResponsiveYoutube.configure({
121
138
  controls: true,
122
139
  nocookie: true,
123
140
  }),
124
141
  TiptapifyImage,
125
142
  TiptapifyVideo,
143
+ VideoFile,
144
+ LinkCard,
145
+ LinkCardPaste,
126
146
  Iframe,
127
147
  Superscript,
128
148
  Subscript,