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.
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +54124 -48070
- package/dist/tiptapify.umd.js +59 -59
- package/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/Footer.vue +12 -2
- package/src/components/ImageBubbleMenu.vue +130 -0
- package/src/components/MenuBubble.vue +36 -21
- package/src/components/Tiptapify.vue +189 -72
- package/src/components/Toolbar/Index.test.ts +96 -0
- package/src/components/Toolbar/Index.vue +225 -14
- package/src/components/Toolbar/Items.test.ts +49 -3
- package/src/components/Toolbar/Items.vue +25 -26
- package/src/components/Toolbar/alignment.ts +5 -0
- package/src/components/UI/TiptapifyDialog.vue +78 -12
- package/src/components/editorExtensions.test.ts +10 -0
- package/src/components/editorExtensions.ts +34 -5
- package/src/components/index.ts +3 -2
- package/src/extensions/components/actions/redo/Button.vue +1 -1
- package/src/extensions/components/actions/undo/Button.vue +1 -1
- package/src/extensions/components/alignment/menu/Button.vue +68 -0
- package/src/extensions/components/list/bullet/Button.vue +16 -1
- package/src/extensions/components/list/numbered/Button.vue +19 -0
- package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
- package/src/extensions/components/list/styles/index.ts +76 -0
- package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
- package/src/extensions/components/media/image/ImageDialog.vue +1 -1
- package/src/extensions/components/media/image/caption.test.ts +122 -0
- package/src/extensions/components/media/image/caption.ts +268 -0
- package/src/extensions/components/media/image/slider.test.ts +106 -0
- package/src/extensions/components/media/image/slider.ts +87 -0
- package/src/extensions/components/style/heading/Button.vue +75 -35
- package/src/i18n/locales/ar.json +22 -0
- package/src/i18n/locales/bg.json +22 -0
- package/src/i18n/locales/ch.json +22 -0
- package/src/i18n/locales/cz.json +22 -0
- package/src/i18n/locales/de.json +22 -0
- package/src/i18n/locales/el.json +22 -0
- package/src/i18n/locales/en.json +23 -1
- package/src/i18n/locales/es.json +22 -0
- package/src/i18n/locales/et.json +22 -0
- package/src/i18n/locales/fi.json +22 -0
- package/src/i18n/locales/fr.json +22 -0
- package/src/i18n/locales/he.json +22 -0
- package/src/i18n/locales/hi.json +22 -0
- package/src/i18n/locales/hr.json +22 -0
- package/src/i18n/locales/hu.json +22 -0
- package/src/i18n/locales/id.json +22 -0
- package/src/i18n/locales/it.json +22 -0
- package/src/i18n/locales/ja.json +22 -0
- package/src/i18n/locales/ko.json +22 -0
- package/src/i18n/locales/la.json +22 -0
- package/src/i18n/locales/lt.json +22 -0
- package/src/i18n/locales/nl.json +22 -0
- package/src/i18n/locales/no.json +22 -0
- package/src/i18n/locales/pl.json +22 -0
- package/src/i18n/locales/pt-BR.json +22 -0
- package/src/i18n/locales/pt.json +22 -0
- package/src/i18n/locales/ro.json +22 -0
- package/src/i18n/locales/ru.json +23 -1
- package/src/i18n/locales/se.json +22 -0
- package/src/i18n/locales/sk.json +22 -0
- package/src/i18n/locales/sl.json +22 -0
- package/src/i18n/locales/sr.json +22 -0
- package/src/i18n/locales/th.json +22 -0
- package/src/i18n/locales/tr.json +22 -0
- package/src/i18n/locales/uk.json +23 -1
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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.
|
|
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
|
|
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"
|
|
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="
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
:
|
|
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
|
-
.
|
|
59
|
-
|
|
53
|
+
.tiptapify-toolbar-group {
|
|
54
|
+
display: flex;
|
|
55
|
+
align-items: center;
|
|
56
|
+
gap: 2px;
|
|
60
57
|
}
|
|
61
58
|
|
|
62
59
|
.menu-divider {
|
|
63
|
-
|
|
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
|
-
.
|
|
71
|
-
flex
|
|
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
|
}
|