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
package/index.d.ts CHANGED
@@ -157,11 +157,41 @@ export type TiptapifySkinConfig = TiptapifyShape & {
157
157
  // The `skin` prop: a registered name (string) or an inline object
158
158
  export type TiptapifySkinInput = string | TiptapifySkinConfig
159
159
 
160
+ export type TiptapifyUploadKind = 'image' | 'video'
161
+
162
+ /** `POST {endpoint}` takes a file (multipart `file` and `kind`) and answers `{ url }`, an error `{ error }` */
163
+ export type TiptapifyUploadOptions = {
164
+ endpoint: string,
165
+ headers?: Record<string, string>,
166
+ }
167
+
168
+ export type TiptapifyLinkPreviewOptions = {
169
+ endpoint: string,
170
+ headers?: Record<string, string>,
171
+ }
172
+
173
+ export type TiptapifyLinkPreviewData = {
174
+ url: string,
175
+ title?: string,
176
+ description?: string,
177
+ image?: string,
178
+ siteName?: string,
179
+ }
180
+
160
181
  export declare function registerSkin(name: string, skin: TiptapifySkinConfig): void
161
182
 
162
183
  // Identity for typed const definitions; the object never enters the registry
163
184
  export declare function defineSkin(skin: TiptapifySkinConfig): TiptapifySkinConfig
164
185
 
186
+ /** How the saved HTML carries the look of its content: `inline` styles, or `classes` for tiptapify/content.css */
187
+ export type TiptapifyContentStyles = 'inline' | 'classes'
188
+
189
+ /** The content HTML with the content styles written into its `style` attributes (unchanged without a DOM) */
190
+ export declare function inlineContentStyles(html: string): string
191
+
192
+ /** The content styles as a stylesheet for the content inside `scope` (`.tiptapify-content`), weightless by default */
193
+ export declare function contentCss(scope?: string, options?: { weightless?: boolean }): string
194
+
165
195
  export interface TiptapifyOptions {
166
196
  i18n?: string
167
197
  }
@@ -183,6 +213,10 @@ export interface TiptapifyAiChatMessage {
183
213
  export interface TiptapifyAiEditorContext {
184
214
  prompt: string
185
215
  selectedText: string
216
+ /** The text of the selection's paragraph before it (empty without a selection) */
217
+ textBefore: string
218
+ /** The text of the selection's paragraph after it (empty without a selection) */
219
+ textAfter: string
186
220
  text: string
187
221
  html: string
188
222
  json: object
@@ -326,6 +360,9 @@ export type {
326
360
 
327
361
  export type { TiptapifyConfig }
328
362
 
363
+ /** The logic of the image dialog, for a host that replaces the dialog with its own */
364
+ export * as imageDialog from './src/extensions/components/media/image/imageDialog'
365
+
329
366
  declare module '@vue/runtime-core' {
330
367
  interface GlobalComponents {
331
368
  Tiptapify: typeof Tiptapify
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tiptapify",
3
3
  "types": "./index.d.ts",
4
- "version": "0.3.5",
4
+ "version": "0.4.1",
5
5
  "description": "Tiptap3 editor with Vuetify3 menu implementation",
6
6
  "exports": {
7
7
  ".": {
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "./src/*": "./src/*",
18
18
  "./dist/*": "./dist/*",
19
- "./style.css": "./dist/tiptapify.css"
19
+ "./style.css": "./dist/tiptapify.css",
20
+ "./content.css": "./dist/content.css"
20
21
  },
21
22
  "main": "./dist/tiptapify.umd.cjs",
22
23
  "module": "./dist/tiptapify.mjs",
@@ -4,18 +4,15 @@ import { Editor } from '@tiptap/vue-3'
4
4
  import { BubbleMenu } from '@tiptap/vue-3/menus'
5
5
  import { NodeSelection } from '@tiptap/pm/state'
6
6
  import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
7
+ import BubbleRow from '@tiptapify/components/UI/BubbleRow.vue'
7
8
  import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
9
  import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
9
10
  import { inject, Ref, ref } from 'vue'
10
11
  import { ComposerTranslation } from 'vue-i18n'
11
- import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
12
12
 
13
13
  // The toolbar on a selected image: float toggles (or, for a slide, its place in the slider), download, edit, removal
14
14
  const editor = inject('tiptapifyEditor') as Ref<Editor>
15
15
 
16
- // The image menu follows the buttons shape flag: a quiet carrier in the quiet shape
17
- const { shape } = useTiptapifyConfig()
18
-
19
16
  // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
20
17
  // box is there, as the menu reads its options only when it is created
21
18
  const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
@@ -66,7 +63,7 @@ function download() {
66
63
  :options="{ placement: 'top', offset: -52, flip: false, scrollTarget }"
67
64
  :should-show="isImageSelected"
68
65
  >
69
- <div class="tiptapify-image-menu" :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }">
66
+ <BubbleRow class="tiptapify-image-menu">
70
67
  <template v-if="isSlide()">
71
68
  <VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(-1)" @click="editor.chain().focus().moveImage(-1).run()">
72
69
  <Tooltip :label="t('media.image_toolbar.move_back')" />
@@ -92,7 +89,7 @@ function download() {
92
89
  </VBtn>
93
90
  </template>
94
91
 
95
- <div class="tiptapify-image-menu__divider" />
92
+ <div class="tiptapify-bubble-divider" />
96
93
 
97
94
  <VBtn size="32" variant="text" color="" @click="download">
98
95
  <Tooltip :label="t('media.image_toolbar.download')" />
@@ -103,37 +100,12 @@ function download() {
103
100
  <BtnIcon :icon="`mdiSvg:${mdi.mdiImageEditOutline}`" />
104
101
  </VBtn>
105
102
 
106
- <div class="tiptapify-image-menu__divider" />
103
+ <div class="tiptapify-bubble-divider" />
107
104
 
108
105
  <VBtn size="32" variant="text" color="" @click="editor.chain().focus().deleteSelection().run()">
109
106
  <Tooltip :label="t('media.image_toolbar.delete')" />
110
107
  <BtnIcon :icon="`mdiSvg:${mdi.mdiTrashCanOutline}`" />
111
108
  </VBtn>
112
- </div>
109
+ </BubbleRow>
113
110
  </BubbleMenu>
114
111
  </template>
115
-
116
- <style scoped lang="scss">
117
- /*
118
- * The image menu follows the bubble tokens: one rule, the classic shape's base as fallback;
119
- * the quiet shape paints its border/radius/shadow from the same tokens.
120
- * The internal divider is this menu's own layout, not the toolbar divider surface.
121
- */
122
- .tiptapify-image-menu {
123
- display: flex;
124
- align-items: center;
125
- gap: var(--tf-bubble-gap, 2px);
126
- padding: var(--tf-bubble-padding, 4px);
127
- border: var(--tf-bubble-border, none);
128
- border-radius: var(--tf-bubble-radius, 6px);
129
- background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
130
- box-shadow: var(--tf-bubble-shadow, 4px 4px 20px var(--dark-gray));
131
- }
132
-
133
- .tiptapify-image-menu__divider {
134
- width: 1px;
135
- height: 20px;
136
- margin: 0 4px;
137
- background: rgba(var(--v-border-color), var(--v-border-opacity));
138
- }
139
- </style>
@@ -0,0 +1,120 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { App, createApp, h, nextTick, provide, ref, shallowRef } from 'vue'
3
+ import { createVuetify } from 'vuetify'
4
+ import { Editor } from '@tiptap/vue-3'
5
+ import { Bold } from '@tiptap/extension-bold'
6
+ import { Code } from '@tiptap/extension-code'
7
+ import { Document } from '@tiptap/extension-document'
8
+ import { Highlight } from '@tiptap/extension-highlight'
9
+ import { Italic } from '@tiptap/extension-italic'
10
+ import { Paragraph } from '@tiptap/extension-paragraph'
11
+ import { Strike } from '@tiptap/extension-strike'
12
+ import { Subscript } from '@tiptap/extension-subscript'
13
+ import { Superscript } from '@tiptap/extension-superscript'
14
+ import { Text } from '@tiptap/extension-text'
15
+ import { Underline } from '@tiptap/extension-underline'
16
+ import MenuBubble from '@tiptapify/components/MenuBubble.vue'
17
+
18
+ // jsdom has no layout of a range, which the menu position reads
19
+ if (!Range.prototype.getClientRects) {
20
+ Range.prototype.getClientRects = () => [] as unknown as DOMRectList
21
+ Range.prototype.getBoundingClientRect = () => new DOMRect()
22
+ }
23
+
24
+ let app: App | null = null
25
+ let editor: Editor | null = null
26
+
27
+ async function flush() {
28
+ for (let i = 0; i < 5; i++) {
29
+ await nextTick()
30
+ }
31
+ }
32
+
33
+ async function mountMenu(): Promise<HTMLElement> {
34
+ editor = new Editor({
35
+ element: document.body.appendChild(document.createElement('div')),
36
+ content: '<p><strong>Bold</strong> text</p>',
37
+ extensions: [Document, Paragraph, Text, Bold, Italic, Underline, Strike, Superscript, Subscript, Highlight, Code],
38
+ })
39
+ // The menu shows for a selection in the focused editor
40
+ editor.commands.focus()
41
+ editor.commands.setTextSelection({ from: 1, to: 5 })
42
+
43
+ app = createApp({
44
+ setup() {
45
+ provide('tiptapifyEditor', shallowRef(editor))
46
+ provide('tiptapifyI18n', { t: (key: string) => key })
47
+ provide('tiptapifyScrollContainer', ref(document.body))
48
+
49
+ return () => h(MenuBubble)
50
+ },
51
+ })
52
+ app.use(createVuetify())
53
+ app.mount(document.body.appendChild(document.createElement('div')))
54
+ await flush()
55
+
56
+ return document.querySelector<HTMLElement>('.tiptapify-bubble-menu')!
57
+ }
58
+
59
+ afterEach(() => {
60
+ app?.unmount()
61
+ editor?.destroy()
62
+ app = null
63
+ editor = null
64
+ document.body.innerHTML = ''
65
+ })
66
+
67
+ describe('selection bubble menu', () => {
68
+ it('is one row of icon buttons in groups split by dividers', async () => {
69
+ const menu = await mountMenu()
70
+ const children = [...menu.children].map(child => (child.classList.contains('tiptapify-bubble-divider') ? '|' : 'btn'))
71
+
72
+ expect(menu.classList).toContain('tiptapify-bubble-row')
73
+ expect(menu.querySelector('.v-card, .v-toolbar, .v-btn-group')).toBeNull()
74
+ expect(children.join(' ')).toBe('btn btn btn btn | btn btn | btn btn | btn | btn')
75
+ })
76
+
77
+ it('marks the active marks of the selection as pressed', async () => {
78
+ const menu = await mountMenu()
79
+ const pressed = [...menu.querySelectorAll('.v-btn')].map(button => button.classList.contains('tiptapify-btn--active'))
80
+
81
+ expect(pressed).toEqual([true, false, false, false, false, false, false, false, false, false])
82
+ expect(menu.querySelector('.v-btn')!.getAttribute('aria-pressed')).toBe('true')
83
+ })
84
+
85
+ it('applies a mark from its button', async () => {
86
+ const menu = await mountMenu()
87
+
88
+ ;(menu.querySelectorAll<HTMLButtonElement>('.v-btn')[1]).click()
89
+ // The reactive state of the vue editor follows a transaction on the next frame
90
+ await new Promise(resolve => setTimeout(resolve, 50))
91
+ await flush()
92
+
93
+ expect(editor!.getHTML()).toBe('<p><strong><em>Bold</em></strong> text</p>')
94
+ expect(document.querySelectorAll('.tiptapify-bubble-menu .v-btn')[1].classList).toContain('tiptapify-btn--active')
95
+ })
96
+ })
97
+
98
+ describe('selection bubble menu indexes', () => {
99
+ it('applies both indexes to the selection, each from its button', async () => {
100
+ const menu = await mountMenu()
101
+ const buttons = () => document.querySelectorAll<HTMLButtonElement>('.tiptapify-bubble-menu .v-btn')
102
+
103
+ expect(menu.querySelectorAll('.v-btn')[4].getAttribute('aria-pressed')).toBe('false')
104
+
105
+ buttons()[4].click()
106
+ await new Promise(resolve => setTimeout(resolve, 50))
107
+ await flush()
108
+
109
+ expect(editor!.getHTML()).toBe('<p><strong><sup>Bold</sup></strong> text</p>')
110
+ expect(buttons()[4].classList).toContain('tiptapify-btn--active')
111
+
112
+ buttons()[5].click()
113
+ await new Promise(resolve => setTimeout(resolve, 50))
114
+ await flush()
115
+
116
+ expect(editor!.getHTML()).toBe('<p><strong><sup><sub>Bold</sub></sup></strong> text</p>')
117
+ expect(buttons()[4].classList).toContain('tiptapify-btn--active')
118
+ expect(buttons()[5].classList).toContain('tiptapify-btn--active')
119
+ })
120
+ })
@@ -1,96 +1,127 @@
1
1
  <script setup lang="ts">
2
2
  import { Editor } from '@tiptap/vue-3'
3
- import { computed, inject, Ref, ref } from 'vue'
3
+ import { inject, Ref, ref } from 'vue'
4
4
  import { BubbleMenu } from '@tiptap/vue-3/menus'
5
5
  import { NodeSelection } from '@tiptap/pm/state'
6
6
  import * as mdi from '@mdi/js'
7
- import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
8
-
9
- defineProps({
10
- variant: { type: String, default () { return 'flat' } },
11
- theme: { type: String, default () { return 'light' } },
12
- })
7
+ import BubbleRow from '@tiptapify/components/UI/BubbleRow.vue'
8
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
9
+ import { ComposerTranslation } from 'vue-i18n'
13
10
 
14
11
  const editor = inject('tiptapifyEditor') as Ref<Editor>
15
12
 
16
- // The bubble shape picks the markup: a flat row of buttons vs the classic card; the quiet
17
- // buttons carrier rides the buttons shape flag
18
- const { shape } = useTiptapifyConfig()
19
-
20
13
  // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
21
14
  // box is there, as the menu reads its options only when it is created
22
15
  const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
23
16
 
24
- const items = ref([
25
- {
26
- name: 'bold',
27
- icon: `mdiSvg:${mdi.mdiFormatBold}`,
28
- props: {
29
- disabled: computed(() => !editor?.value.can().chain().focus().toggleBold().run()),
30
- color: computed(() => editor.value.isActive('bold') ? 'primary' : ''),
17
+ type BubbleItem = {
18
+ name: string,
19
+ icon: string,
20
+ label: string,
21
+ hotkey?: string,
22
+ active?: () => boolean,
23
+ disabled?: () => boolean,
24
+ click: () => void,
25
+ }
26
+
27
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
28
+
29
+ const groups: BubbleItem[][] = [
30
+ [
31
+ {
32
+ name: 'bold',
33
+ icon: mdi.mdiFormatBold,
34
+ label: 'format.bold',
35
+ hotkey: 'Mod-b',
36
+ active: () => editor.value.isActive('bold'),
37
+ disabled: () => !editor.value.can().chain().focus().toggleBold().run(),
38
+ click: () => editor.value.chain().focus().toggleBold().run(),
39
+ },
40
+ {
41
+ name: 'italic',
42
+ icon: mdi.mdiFormatItalic,
43
+ label: 'format.italic',
44
+ hotkey: 'Mod-i',
45
+ active: () => editor.value.isActive('italic'),
46
+ disabled: () => !editor.value.can().chain().focus().toggleItalic().run(),
47
+ click: () => editor.value.chain().focus().toggleItalic().run(),
48
+ },
49
+ {
50
+ name: 'underline',
51
+ icon: mdi.mdiFormatUnderline,
52
+ label: 'format.underline',
53
+ hotkey: 'Mod-u',
54
+ active: () => editor.value.isActive('underline'),
55
+ disabled: () => !editor.value.can().chain().focus().toggleUnderline().run(),
56
+ click: () => editor.value.chain().focus().toggleUnderline().run(),
31
57
  },
32
- click: () => editor.value.chain().focus().toggleBold().run(),
33
- },
34
- {
35
- name: 'italic',
36
- icon: `mdiSvg:${mdi.mdiFormatItalic}`,
37
- props: {
38
- disabled: computed(() => !editor.value.can().chain().focus().toggleItalic().run()),
39
- color: computed(() => editor.value.isActive('italic') ? 'primary' : ''),
58
+ {
59
+ name: 'strike',
60
+ icon: mdi.mdiFormatStrikethroughVariant,
61
+ label: 'format.strike',
62
+ hotkey: 'Mod-Shift-s',
63
+ active: () => editor.value.isActive('strike'),
64
+ disabled: () => !editor.value.can().chain().focus().toggleStrike().run(),
65
+ click: () => editor.value.chain().focus().toggleStrike().run(),
40
66
  },
41
- click: () => editor.value.chain().focus().toggleItalic().run(),
42
- },
43
- {
44
- name: 'strike',
45
- icon: `mdiSvg:${mdi.mdiFormatStrikethroughVariant}`,
46
- props: {
47
- disabled: computed(() => !editor.value.can().chain().focus().toggleStrike().run()),
48
- color: computed(() => editor.value.isActive('strike') ? 'primary' : ''),
67
+ ],
68
+ [
69
+ {
70
+ name: 'superscript',
71
+ icon: mdi.mdiFormatSuperscript,
72
+ label: 'format.sup',
73
+ hotkey: 'Mod-.',
74
+ active: () => editor.value.isActive('superscript'),
75
+ disabled: () => !editor.value.can().chain().focus().toggleSuperscript().run(),
76
+ click: () => editor.value.chain().focus().toggleSuperscript().run(),
49
77
  },
50
- click: () => editor.value.chain().focus().toggleStrike().run(),
51
- },
52
- {
53
- name: 'underline',
54
- icon: `mdiSvg:${mdi.mdiFormatUnderline}`,
55
- props: {
56
- disabled: computed(() => !editor.value.can().chain().focus().toggleUnderline().run()),
57
- color: computed(() => editor.value.isActive('underline') ? 'primary' : ''),
78
+ {
79
+ name: 'subscript',
80
+ icon: mdi.mdiFormatSubscript,
81
+ label: 'format.sub',
82
+ hotkey: 'Mod-,',
83
+ active: () => editor.value.isActive('subscript'),
84
+ disabled: () => !editor.value.can().chain().focus().toggleSubscript().run(),
85
+ click: () => editor.value.chain().focus().toggleSubscript().run(),
58
86
  },
59
- click: () => editor.value.chain().focus().toggleUnderline().run(),
60
- },
61
- {
62
- name: 'highlight',
63
- icon: `mdiSvg:${mdi.mdiFormatColorHighlight}`,
64
- props: {
65
- disabled: computed(() => !editor.value.can().chain().focus().toggleHighlight().run()),
66
- color: computed(() => editor.value.isActive('highlight') ? 'primary' : ''),
87
+ ],
88
+ [
89
+ {
90
+ name: 'highlight',
91
+ icon: mdi.mdiFormatColorHighlight,
92
+ label: 'style.color.highlight',
93
+ active: () => editor.value.isActive('highlight'),
94
+ disabled: () => !editor.value.can().chain().focus().toggleHighlight().run(),
95
+ click: () => editor.value.chain().focus().toggleHighlight().run(),
67
96
  },
68
- click: () => editor.value.chain().focus().toggleHighlight().run(),
69
- },
70
- {
71
- name: 'code',
72
- icon: `mdiSvg:${mdi.mdiXml}`,
73
- props: {
74
- disabled: computed(() => !editor.value.can().chain().focus().toggleCode().run()),
75
- color: computed(() => editor.value.isActive('code') ? 'primary' : ''),
97
+ {
98
+ name: 'code',
99
+ icon: mdi.mdiCodeTags,
100
+ label: 'format.code',
101
+ hotkey: 'Mod-e',
102
+ active: () => editor.value.isActive('code'),
103
+ disabled: () => !editor.value.can().chain().focus().toggleCode().run(),
104
+ click: () => editor.value.chain().focus().toggleCode().run(),
76
105
  },
77
- click: () => editor.value.chain().focus().toggleCode().run(),
78
- },
79
- {
80
- name: 'link',
81
- icon: computed(() => editor.value.isActive('link') ? `mdiSvg:${mdi.mdiLinkOff}` : `mdiSvg:${mdi.mdiLink}`),
82
- props: {
83
- color: computed(() => editor.value.isActive('link') ? 'primary' : ''),
84
- disabled: computed(() => editor.value.isActive('code') || editor.value.isActive('codeBlock')),
106
+ ],
107
+ [
108
+ {
109
+ name: 'link',
110
+ icon: mdi.mdiLink,
111
+ label: 'media.link',
112
+ active: () => editor.value.isActive('link'),
113
+ click: () => editor.value.commands.showLink(),
85
114
  },
86
- click: () => editor.value.commands.showLink()
87
- },
88
- {
89
- name: 'format clear',
90
- icon: `mdiSvg:${mdi.mdiFormatClear}`,
91
- click: () => editor.value.chain().focus().unsetAllMarks().clearNodes().run(),
92
- }
93
- ])
115
+ ],
116
+ [
117
+ {
118
+ name: 'format clear',
119
+ icon: mdi.mdiFormatClear,
120
+ label: 'format.formatClear',
121
+ click: () => editor.value.chain().focus().unsetAllMarks().clearNodes().run(),
122
+ },
123
+ ],
124
+ ]
94
125
  </script>
95
126
 
96
127
  <template>
@@ -114,65 +145,24 @@ const items = ref([
114
145
  return (from !== to) && !isAllSelected
115
146
  }"
116
147
  >
117
- <div
118
- v-if="shape.bubble === 'flat'"
119
- class="tiptapify-bubble-menu"
120
- :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }"
121
- >
122
- <VBtn
123
- v-for="(item, key) in items"
124
- :key="key"
125
- size="32"
126
- variant="text"
127
- color=""
128
- :disabled="item.props?.disabled"
129
- :class="{ 'tiptapify-btn--active': item.props?.color === 'primary' }"
130
- @click="item.click"
131
- >
132
- <VIcon :icon="item.icon" size="20" />
133
- </VBtn>
134
- </div>
135
-
136
- <!-- The classic bubble menu: a small card with a divided group of compact buttons -->
137
- <div v-else class="bubble-menu">
138
- <VCard>
139
- <VCardText class="pa-0">
140
- <VToolbar :theme="theme" density="compact" height="auto" class="p-0">
141
- <VToolbarItems>
142
- <VBtnGroup divided density="compact">
143
- <VBtn v-for="(item, key) in items" :key="key" v-bind="item.props" size="x-small" @click="item.click">
144
- <VIcon :icon="item.icon" size="20" />
145
- </VBtn>
146
- </VBtnGroup>
147
- </VToolbarItems>
148
- </VToolbar>
149
- </VCardText>
150
- </VCard>
151
- </div>
148
+ <BubbleRow class="tiptapify-bubble-menu">
149
+ <template v-for="(group, index) in groups" :key="index">
150
+ <div v-if="index > 0" class="tiptapify-bubble-divider" />
151
+ <VBtn
152
+ v-for="item in group"
153
+ :key="item.name"
154
+ size="32"
155
+ variant="text"
156
+ color=""
157
+ :disabled="item.disabled?.()"
158
+ :class="{ 'tiptapify-btn--active': item.active?.() }"
159
+ :aria-pressed="item.active ? item.active() : undefined"
160
+ @click="item.click"
161
+ >
162
+ <Tooltip :label="t(item.label)" :hotkey="item.hotkey" />
163
+ <VIcon :icon="`mdiSvg:${item.icon}`" size="20" />
164
+ </VBtn>
165
+ </template>
166
+ </BubbleRow>
152
167
  </BubbleMenu>
153
168
  </template>
154
-
155
- <style scoped lang="scss">
156
- /*
157
- * Bubble skin rules: one rule per shape branch, reading `var(--tf-*)`.
158
- * The template branch is the shape gate (`bubble: card` renders the classic card,
159
- * `bubble: flat` the quiet row), so each rule carries its shape's base as fallback.
160
- * The classic card's background/border/padding/gap are VCard/VToolbar layout (not
161
- * token dimensions); the flat row paints them from tokens.
162
- */
163
- .bubble-menu {
164
- border-radius: var(--tf-bubble-radius, 6px);
165
- box-shadow: var(--tf-bubble-shadow, 4px 4px 20px var(--dark-gray));
166
- }
167
-
168
- .tiptapify-bubble-menu {
169
- display: flex;
170
- align-items: center;
171
- gap: var(--tf-bubble-gap, 2px);
172
- padding: var(--tf-bubble-padding, 4px);
173
- border: var(--tf-bubble-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
174
- border-radius: var(--tf-bubble-radius, 10px);
175
- background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
176
- box-shadow: var(--tf-bubble-shadow, 0 6px 20px rgba(0, 0, 0, 0.12));
177
- }
178
- </style>