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
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createSSRApp, defineComponent, h, markRaw, provide, shallowRef } from 'vue'
3
+ import { renderToString } from 'vue/server-renderer'
4
+ import { createVuetify } from 'vuetify'
5
+ import Toolbar from '@tiptapify/components/Toolbar/Index.vue'
6
+
7
+ // Every editor call answers with something callable, so toolbar buttons can render
8
+ function permissive (): unknown {
9
+ return new Proxy(() => false, {
10
+ get: (_, key) => {
11
+ if (key === '__v_skip') {
12
+ return true
13
+ }
14
+ if (key === Symbol.toPrimitive) {
15
+ return () => ''
16
+ }
17
+ if (typeof key === 'symbol' || key.startsWith('__v') || key === 'then') {
18
+ return undefined
19
+ }
20
+
21
+ return key === 'instanceId' ? 1 : permissive()
22
+ },
23
+ apply: () => permissive(),
24
+ })
25
+ }
26
+
27
+ const stub = (className: string) => markRaw(defineComponent({ render: () => h('i', { class: className }) }))
28
+
29
+ async function renderToolbar (items: string[], customExtensions: unknown[]) {
30
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
31
+ const app = createSSRApp({
32
+ setup () {
33
+ provide('tiptapifyEditor', shallowRef(permissive()))
34
+ provide('tiptapifyI18n', { t: (key: string) => key })
35
+
36
+ return () => h(Toolbar, { items, customExtensions })
37
+ },
38
+ })
39
+ app.use(createVuetify())
40
+
41
+ return renderToString(app)
42
+ }
43
+
44
+ describe('Toolbar', () => {
45
+ it('adds a custom button to an existing section after its own buttons', async () => {
46
+ const html = await renderToolbar(['sup', 'blockquote'], [
47
+ { section: 'formatExtra', group: true, components: [{ name: 'disclaimer', component: stub('custom-disclaimer') }] },
48
+ ])
49
+
50
+ expect(html).toContain('custom-disclaimer')
51
+ expect(html.indexOf('custom-disclaimer')).toBeGreaterThan(html.indexOf('tiptapify-toolbar-group'))
52
+ })
53
+
54
+ it('puts custom sections before the spacer', async () => {
55
+ const html = await renderToolbar(['bold', '~', 'undo'], [
56
+ { section: 'extra', group: true, components: [{ name: 'snippet', component: stub('custom-snippet') }] },
57
+ ])
58
+
59
+ expect(html.indexOf('custom-snippet')).toBeLessThan(html.indexOf('tiptapify-toolbar-spacer'))
60
+ })
61
+
62
+ it('does not leak custom buttons into the next editor', async () => {
63
+ await renderToolbar([], [
64
+ { section: 'formatExtra', group: true, components: [{ name: 'disclaimer', component: stub('custom-disclaimer') }] },
65
+ ])
66
+ const html = await renderToolbar([], [])
67
+
68
+ expect(html).not.toContain('custom-disclaimer')
69
+ })
70
+
71
+ it('groups the items by the separators, across sections', async () => {
72
+ const html = await renderToolbar(['bold', 'link', '|', 'blockquote', 'custom-note', '|', 'undo'], [
73
+ { section: 'extra', group: true, components: [{ name: 'custom-note', component: stub('custom-note') }] },
74
+ ])
75
+
76
+ const groups = html.split('tiptapify-toolbar-group').slice(1)
77
+ expect(groups).toHaveLength(3)
78
+ expect(groups[1]).toContain('custom-note')
79
+ expect(groups[0]).not.toContain('custom-note')
80
+ })
81
+
82
+ it('drops the place of a custom button the editor does not have', async () => {
83
+ const html = await renderToolbar(['bold', '|', 'custom-note', '|', 'undo'], [])
84
+
85
+ expect(html.split('tiptapify-toolbar-group').slice(1)).toHaveLength(2)
86
+ })
87
+
88
+ it('replaces a built-in button by a custom one of the same name in its place', async () => {
89
+ const html = await renderToolbar(['bold', '|', 'image', 'link'], [
90
+ { section: 'media', group: true, components: [{ name: 'image', component: stub('custom-image') }] },
91
+ ])
92
+
93
+ expect(html).toContain('custom-image')
94
+ expect(html.split('tiptapify-toolbar-group').slice(1)).toHaveLength(2)
95
+ })
96
+ })
@@ -27,16 +27,65 @@ const propsItems = computed(() => props.items)
27
27
  const propsItemsExclude = computed(() => props.itemsExclude)
28
28
 
29
29
  // this prevents from overriding the default items
30
- let toolbarItems = [...Object.values(toolbarItemsGroups)]
30
+ let toolbarItems = Object.values(toolbarItemsGroups).map(section => ({
31
+ ...section,
32
+ components: section.components.map(component => ({ ...component })),
33
+ }))
31
34
  const availableItemsKeys = Object.keys(availableItems)
32
35
 
33
36
  type toolbarItemsType = { [key: string]: { section: string, group: boolean, components: any[] } }
34
37
  if (propsItemsExclude.value) {
35
38
  prepareExcludeToolbarItems()
39
+ } else if (Array.isArray(propsItems.value) && propsItems.value.includes('|')) {
40
+ prepareGroupedToolbarItems(propsItems.value as string[])
36
41
  } else {
37
42
  prepareToolbarItems()
38
43
  }
39
44
 
45
+ /**
46
+ * Items split by `'|'` into groups of their own, whatever sections the buttons come from. A name that is
47
+ * not a built-in item keeps a place for the custom button of that name; editors without it drop the place.
48
+ */
49
+ function prepareGroupedToolbarItems(items: string[]) {
50
+ const groups: toolbarItemsType[string][] = []
51
+ const newGroup = () => ({ section: `__group_${groups.length}__`, group: true, components: [] as any[] })
52
+ let group = newGroup()
53
+
54
+ const flush = () => {
55
+ if (group.components.length > 0) {
56
+ groups.push(group)
57
+ }
58
+ group = newGroup()
59
+ }
60
+
61
+ for (const propsItem of items) {
62
+ if (propsItem === '|') {
63
+ flush()
64
+ continue
65
+ }
66
+
67
+ if (propsItem === '~') {
68
+ flush()
69
+ groups.push({ section: '__spacer__', group: false, components: [] })
70
+ group = newGroup()
71
+ continue
72
+ }
73
+
74
+ const itemName = propsItem.split(':')[0]
75
+ if (!availableItemsKeys.includes(itemName)) {
76
+ group.components.push({ name: itemName, component: null })
77
+ continue
78
+ }
79
+
80
+ const itemSections: toolbarItemsType = {}
81
+ addToolbarItem(itemSections, items, propsItem)
82
+ group.components.push(...Object.values(itemSections).flatMap(section => section.components))
83
+ }
84
+ flush()
85
+
86
+ toolbarItems = groups
87
+ }
88
+
40
89
  function prepareToolbarItems() {
41
90
  const _toolbarItems: toolbarItemsType = {}
42
91
 
@@ -67,6 +116,15 @@ function prepareToolbarItems() {
67
116
  }
68
117
  continue
69
118
  }
119
+ // `~` pushes the items after it to the end of the toolbar
120
+ if (propsItem === '~') {
121
+ _toolbarItems['__spacer__'] ??= {
122
+ section: '__spacer__',
123
+ group: false,
124
+ components: []
125
+ }
126
+ continue
127
+ }
70
128
  const item = propsItem.split(':')
71
129
  if (!availableItemsKeys.includes(item[0])) {
72
130
  throw new Error(`The ${propsItem} is unknown extension. Please use one of the following: ${availableItemsKeys.join(', ')}`)
@@ -105,7 +163,9 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
105
163
  const itemSection = availableItems[_itemTitle]
106
164
  const section = toolbarItemsGroups[itemSection]
107
165
 
108
- const component = section?.components.find((component: { name: string }) => component.name === _itemTitle)
166
+ // A copy: item options set its props, which must not reach the toolbars of other editors
167
+ const sectionComponent = section?.components.find((component: { name: string }) => component.name === _itemTitle)
168
+ const component = sectionComponent ? { ...sectionComponent } : undefined
109
169
 
110
170
  let itemExists = itemsList.includes(itemTitle)
111
171
  itemExists = propsItemsExclude.value ? itemExists : !itemExists
@@ -132,12 +192,33 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
132
192
  _itemOptions.splice(paragraphIndex, 1)
133
193
  }
134
194
 
195
+ const inlineIndex = _itemOptions.findIndex((item: string) => item === 'inline')
196
+ const inline = inlineIndex > -1
197
+ if (inline) {
198
+ _itemOptions.splice(inlineIndex, 1)
199
+ }
200
+
135
201
  component.props = {
136
202
  withParagraph,
203
+ inline,
137
204
  customHeadingLevels: _itemOptions.map((item: string) => parseInt(item)),
138
205
  }
139
206
  }
140
207
 
208
+ // `orderedList:styles` adds the numbering styles dropdown
209
+ if (_itemTitle === 'orderedList' && _itemOptions.includes('styles')) {
210
+ component.props = {
211
+ withStyles: true,
212
+ }
213
+ }
214
+
215
+ // `align` is the alignment dropdown; `align:left,center,right` limits its options
216
+ if (_itemTitle === 'align' && _itemOptions.length > 0) {
217
+ component.props = {
218
+ alignments: _itemOptions,
219
+ }
220
+ }
221
+
141
222
  if (_itemTitle === 'bulletList' && _itemOptions.length > 0) {
142
223
  const paragraphIndex = _itemOptions.findIndex((item: any) => item === 'p')
143
224
  const withParagraph = paragraphIndex > -1
@@ -146,6 +227,7 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
146
227
  }
147
228
 
148
229
  component.props = {
230
+ withStyles: _itemOptions.includes('styles'),
149
231
  withDisc: _itemOptions.includes('disc'),
150
232
  withCircle: _itemOptions.includes('circle'),
151
233
  withSquare: _itemOptions.includes('square'),
@@ -157,25 +239,40 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
157
239
  return true
158
240
  }
159
241
 
160
- Object.keys(props.customExtensions).forEach((key: any) => {
161
- const existingSection = toolbarItems.find(item => item.section === props.customExtensions[key].section)
162
- if (existingSection) {
163
- for (const component of props.customExtensions[key].components) {
164
- const existingComponent = existingSection.components.find(_component => _component.name === component.name)
165
- if (existingComponent) {
166
- existingComponent.component = component.component
167
- }
242
+ // A custom button takes the place of the item of its name, joins its section or comes as a section of its own
243
+ for (const customSection of props.customExtensions) {
244
+ const unplaced = []
245
+ for (const component of customSection.components) {
246
+ const place = toolbarItems.flatMap(item => item.components).find(_component => _component.name === component.name)
247
+ if (place) {
248
+ Object.assign(place, component)
249
+ } else {
250
+ unplaced.push(component)
168
251
  }
252
+ }
253
+
254
+ if (unplaced.length === 0) {
255
+ continue
256
+ }
257
+
258
+ const existingSection = toolbarItems.find(item => item.section === customSection.section)
259
+ if (existingSection) {
260
+ existingSection.components.push(...unplaced)
169
261
  } else {
170
- toolbarItems.push(props.customExtensions[key])
262
+ const spacerIndex = toolbarItems.findIndex(item => item.section === '__spacer__')
263
+ toolbarItems.splice(spacerIndex > -1 ? spacerIndex : toolbarItems.length, 0, { ...customSection, components: unplaced })
171
264
  }
172
- })
265
+ }
266
+
267
+ toolbarItems = toolbarItems
268
+ .map(item => ({ ...item, components: item.components.filter(component => component.component) }))
269
+ .filter(item => item.components.length > 0 || ['__spacer__', '__separator__'].includes(item.section))
173
270
 
174
271
  </script>
175
272
 
176
273
  <template>
177
274
  <div v-if="editor" class="tiptapify-toolbar">
178
- <VToolbar elevation="1" :theme="theme" height="auto" :class="`ps-1 pr-1 rounded-t-${rounded}`">
275
+ <VToolbar flat :theme="theme" height="auto" :class="`rounded-t-${rounded}`">
179
276
  <VSlideGroup v-if="toolbarScrollable">
180
277
  <Items :items="toolbarItems" />
181
278
  </VSlideGroup>
@@ -185,6 +282,120 @@ Object.keys(props.customExtensions).forEach((key: any) => {
185
282
  </div>
186
283
  </template>
187
284
 
188
- <style lang="scss" scoped>
285
+ <style lang="scss">
286
+ /*
287
+ * Quiet toolbar: a tinted bar under a hairline, ghost icon buttons that only get a background on
288
+ * hover, and a neutral "pressed" state instead of the filled primary button. The buttons keep
289
+ * coloring themselves with `color="primary"` when active; the toolbar maps that to the pressed look.
290
+ */
291
+ .tiptapify-toolbar {
292
+ > .v-toolbar {
293
+ background: rgba(var(--v-theme-on-surface), 0.03);
294
+ border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
295
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
296
+ }
297
+ }
298
+
299
+ /*
300
+ * Quiet buttons of the toolbar and the bubble menus. Tripled class: outranks Vuetify's color utilities and
301
+ * the theme rules that repaint colored buttons on hover, focus and press (`.bg-primary:focus` and the like).
302
+ * Active buttons keep `color="primary"` (or `.tiptapify-btn--active`) and get the neutral pressed look.
303
+ */
304
+ .tiptapify-toolbar,
305
+ .tiptapify-quiet-buttons {
306
+ .v-btn.v-btn.v-btn {
307
+ border-radius: 6px;
308
+ box-shadow: none !important;
309
+ background-color: transparent !important;
310
+ color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)) !important;
311
+ transition: background-color 0.15s, color 0.15s;
312
+
313
+ > .v-btn__overlay,
314
+ > .v-btn__underlay {
315
+ display: none;
316
+ }
317
+
318
+ &:hover,
319
+ &:focus-visible,
320
+ &[aria-expanded='true'] {
321
+ background-color: rgba(var(--v-theme-on-surface), 0.07) !important;
322
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
323
+ }
324
+
325
+ &.bg-primary,
326
+ &.text-primary,
327
+ &.tiptapify-btn--active {
328
+ &,
329
+ &:focus,
330
+ &:active {
331
+ background-color: rgba(var(--v-theme-on-surface), 0.1) !important;
332
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
333
+ }
334
+
335
+ &:hover,
336
+ &:focus-visible,
337
+ &[aria-expanded='true'] {
338
+ background-color: rgba(var(--v-theme-on-surface), 0.14) !important;
339
+ }
340
+ }
341
+
342
+ &:focus-visible {
343
+ outline: 2px solid rgba(var(--v-theme-on-surface), 0.25);
344
+ outline-offset: -2px;
345
+ }
346
+ }
347
+ }
189
348
 
349
+ /* A toolbar button that opens a dropdown: the current choice and a chevron */
350
+ .tiptapify-toolbar .v-btn.tiptapify-dropdown-button {
351
+ min-width: 0;
352
+ padding-inline: 6px 4px;
353
+
354
+ .tiptapify-dropdown-button__chevron {
355
+ margin-inline-start: 2px;
356
+ opacity: 0.7;
357
+ }
358
+ }
359
+
360
+ /* Dropdowns opened from the toolbar: a light card with compact rows of icon + label. */
361
+ .tiptapify-menu {
362
+ /* Doubled classes: the admin theme pads menu lists and items with its own !important rules */
363
+ > .v-list.v-list {
364
+ padding: 4px !important;
365
+ border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
366
+ border-radius: 8px;
367
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
368
+
369
+ > .v-list-item.v-list-item {
370
+ min-height: 34px;
371
+ margin: 0 !important;
372
+ padding: 4px 8px !important;
373
+ border-radius: 6px;
374
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
375
+
376
+ .v-list-item__prepend > .v-icon {
377
+ margin-inline-end: 0;
378
+ opacity: 0.6;
379
+ }
380
+
381
+ .v-list-item__spacer {
382
+ width: 10px !important;
383
+ }
384
+
385
+ .v-list-item-title {
386
+ font-size: 14px;
387
+ font-weight: 500;
388
+ }
389
+
390
+ &.v-list-item--active {
391
+ background: rgba(var(--v-theme-on-surface), 0.06);
392
+ color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)) !important;
393
+
394
+ > .v-list-item__overlay {
395
+ opacity: 0;
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
190
401
  </style>
@@ -1,9 +1,11 @@
1
- import { describe, expect, it, vi } from 'vitest'
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
2
  import { createSSRApp, h, provide, ref } from 'vue'
3
3
  import { renderToString } from 'vue/server-renderer'
4
4
  import { createVuetify } from 'vuetify'
5
5
  import Items from '@tiptapify/components/Toolbar/Items.vue'
6
6
  import HeadingButton from '@tiptapify/extensions/components/style/heading/Button.vue'
7
+ import AlignmentMenuButton from '@tiptapify/extensions/components/alignment/menu/Button.vue'
8
+ import { headingLevels } from '@tiptapify/constants/style'
7
9
 
8
10
  const editor = {
9
11
  instanceId: 1,
@@ -21,7 +23,7 @@ const items = [
21
23
  },
22
24
  ]
23
25
 
24
- async function renderToolbar () {
26
+ async function renderToolbar (toolbarItems: typeof items = items) {
25
27
  const warnings: string[] = []
26
28
  const warn = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
27
29
  warnings.push(args.map(String).join(' '))
@@ -32,7 +34,7 @@ async function renderToolbar () {
32
34
  provide('tiptapifyEditor', ref(editor))
33
35
  provide('tiptapifyI18n', { t: (key: string) => key })
34
36
 
35
- return () => h(Items, { items })
37
+ return () => h(Items, { items: toolbarItems })
36
38
  },
37
39
  })
38
40
  app.use(createVuetify())
@@ -44,10 +46,54 @@ async function renderToolbar () {
44
46
  }
45
47
 
46
48
  describe('Toolbar Items', () => {
49
+ afterEach(() => {
50
+ headingLevels.value = [1, 2, 3, 4, 5, 6]
51
+ })
52
+
47
53
  it('does not pass undeclared attributes to toolbar buttons', async () => {
48
54
  const { html, warnings } = await renderToolbar()
49
55
 
50
56
  expect(html).toContain('tiptapify-heading-button-1')
51
57
  expect(warnings.join('\n')).not.toContain('Extraneous non-props attributes')
52
58
  })
59
+
60
+ it('renders heading levels as separate buttons in inline mode', async () => {
61
+ const { html } = await renderToolbar([
62
+ {
63
+ section: 'style',
64
+ group: true,
65
+ components: [{
66
+ name: 'heading',
67
+ component: HeadingButton,
68
+ props: { inline: true, withParagraph: true, customHeadingLevels: [2, 3] },
69
+ }],
70
+ },
71
+ ])
72
+
73
+ expect(html).not.toContain('tiptapify-heading-button-1')
74
+ expect(html.match(/<button/g)).toHaveLength(3)
75
+ })
76
+
77
+ it('renders a spacer that pushes the next items to the end', async () => {
78
+ const { html } = await renderToolbar([
79
+ items[0],
80
+ { section: '__spacer__', group: false, components: [] },
81
+ { section: 'actions', group: true, components: [{ name: 'heading', component: HeadingButton }] },
82
+ ])
83
+
84
+ expect(html).toContain('tiptapify-toolbar-spacer')
85
+ })
86
+
87
+ it('renders the alignment dropdown with the chosen alignments', async () => {
88
+ const { html } = await renderToolbar([
89
+ {
90
+ section: 'alignment',
91
+ group: true,
92
+ components: [{ name: 'align', component: AlignmentMenuButton, props: { alignments: ['left', 'center'] } }],
93
+ },
94
+ ])
95
+
96
+ expect(html).toContain('tiptapify-align-button-1')
97
+ expect(html).toContain('tiptapify-dropdown-button__chevron')
98
+ })
53
99
  })
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
 
3
- import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
4
3
  import { PropType } from 'vue'
5
4
 
6
5
  import { toolbarSections } from '@tiptapify/types/toolbarTypes'
@@ -9,22 +8,23 @@ defineProps({
9
8
  items: { type: Array as PropType<toolbarSections>, default() { return {} } },
10
9
  })
11
10
 
12
- const { variantBtn } = useTiptapifyConfig()
13
-
14
11
  </script>
15
12
 
16
13
  <template>
17
- <VToolbarItems class="px-2 py-3">
14
+ <VToolbarItems class="tiptapify-toolbar-items">
18
15
  <template v-for="item in items" :key="item.section">
19
16
  <template v-if="item.section === '__separator__'">
20
17
  <div class="menu-divider" />
21
18
  </template>
19
+ <template v-else-if="item.section === '__spacer__'">
20
+ <div class="tiptapify-toolbar-spacer" />
21
+ </template>
22
22
  <template v-else>
23
- <VBtnGroup v-if="item.group" elevation="4" :class="{ 'tiptapify-btn-group--elevated': variantBtn === 'elevated' }">
23
+ <div v-if="item.group" class="tiptapify-toolbar-group">
24
24
  <template v-for="sectionItem in item.components" :key="sectionItem.name">
25
25
  <component :is="sectionItem.component" v-bind="sectionItem.props ?? {}" />
26
26
  </template>
27
- </VBtnGroup>
27
+ </div>
28
28
  <template v-else>
29
29
  <component
30
30
  :is="sectionItem.component"
@@ -40,35 +40,34 @@ const { variantBtn } = useTiptapifyConfig()
40
40
  </template>
41
41
 
42
42
  <style lang="scss" scoped>
43
- .tiptapify-btn-group--elevated {
44
- box-shadow:
45
- 0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)),
46
- 0 2px 2px 0 var(--v-shadow-key-penumbra-opacity, rgba(0, 0, 0, 0.14)),
47
- 0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12));
48
- }
49
-
50
- :deep(.v-btn-group) {
51
- height: 32px !important;
52
- }
53
-
54
- .menu-item-title {
55
- font-size: 14px;
43
+ .tiptapify-toolbar-items {
44
+ /* Vuetify sizes .v-toolbar-items to its content; grow it to the full toolbar width,
45
+ * otherwise the `~` spacer has no free space to expand into. */
46
+ flex-grow: 1;
47
+ flex-wrap: wrap;
48
+ align-items: center;
49
+ gap: 4px 6px;
50
+ padding: 6px 8px;
56
51
  }
57
52
 
58
- .menu-button {
59
- margin: 0 1px;
53
+ .tiptapify-toolbar-group {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 2px;
60
57
  }
61
58
 
62
59
  .menu-divider {
63
- margin: 0 4px;
60
+ width: 1px;
61
+ height: 20px;
62
+ background: rgba(var(--v-border-color), var(--v-border-opacity));
64
63
  }
65
64
 
66
- .menu-divider:nth-last-child(1) {
65
+ .menu-divider:nth-last-child(1),
66
+ .menu-divider:has(+ .tiptapify-toolbar-spacer) {
67
67
  display: none;
68
68
  }
69
69
 
70
- .v-toolbar-items {
71
- flex-wrap: wrap;
72
- row-gap: 5px;
70
+ .tiptapify-toolbar-spacer {
71
+ flex: 1 0 0;
73
72
  }
74
73
  </style>
@@ -2,6 +2,7 @@ import { default as AlignmentLeftButton } from '@tiptapify/extensions/components
2
2
  import { default as AlignmentCenterButton } from '@tiptapify/extensions/components/alignment/center/Button.vue'
3
3
  import { default as AlignmentRightButton } from '@tiptapify/extensions/components/alignment/right/Button.vue'
4
4
  import { default as AlignmentJustifyButton } from '@tiptapify/extensions/components/alignment/justify/Button.vue'
5
+ import { default as AlignmentMenuButton } from '@tiptapify/extensions/components/alignment/menu/Button.vue'
5
6
  import { markRaw } from 'vue'
6
7
 
7
8
  export default {
@@ -24,5 +25,9 @@ export default {
24
25
  name: 'justify',
25
26
  component: markRaw(AlignmentJustifyButton),
26
27
  },
28
+ {
29
+ name: 'align',
30
+ component: markRaw(AlignmentMenuButton),
31
+ },
27
32
  ]
28
33
  }