tiptapify 0.3.2 → 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 +54029 -48016
- 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 +218 -14
- package/src/components/Toolbar/Items.test.ts +24 -0
- 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 +22 -11
- 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
|
|
@@ -145,6 +205,20 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
|
|
|
145
205
|
}
|
|
146
206
|
}
|
|
147
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
|
+
|
|
148
222
|
if (_itemTitle === 'bulletList' && _itemOptions.length > 0) {
|
|
149
223
|
const paragraphIndex = _itemOptions.findIndex((item: any) => item === 'p')
|
|
150
224
|
const withParagraph = paragraphIndex > -1
|
|
@@ -153,6 +227,7 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
|
|
|
153
227
|
}
|
|
154
228
|
|
|
155
229
|
component.props = {
|
|
230
|
+
withStyles: _itemOptions.includes('styles'),
|
|
156
231
|
withDisc: _itemOptions.includes('disc'),
|
|
157
232
|
withCircle: _itemOptions.includes('circle'),
|
|
158
233
|
withSquare: _itemOptions.includes('square'),
|
|
@@ -164,25 +239,40 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
|
|
|
164
239
|
return true
|
|
165
240
|
}
|
|
166
241
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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)
|
|
175
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)
|
|
176
261
|
} else {
|
|
177
|
-
toolbarItems.
|
|
262
|
+
const spacerIndex = toolbarItems.findIndex(item => item.section === '__spacer__')
|
|
263
|
+
toolbarItems.splice(spacerIndex > -1 ? spacerIndex : toolbarItems.length, 0, { ...customSection, components: unplaced })
|
|
178
264
|
}
|
|
179
|
-
}
|
|
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))
|
|
180
270
|
|
|
181
271
|
</script>
|
|
182
272
|
|
|
183
273
|
<template>
|
|
184
274
|
<div v-if="editor" class="tiptapify-toolbar">
|
|
185
|
-
<VToolbar
|
|
275
|
+
<VToolbar flat :theme="theme" height="auto" :class="`rounded-t-${rounded}`">
|
|
186
276
|
<VSlideGroup v-if="toolbarScrollable">
|
|
187
277
|
<Items :items="toolbarItems" />
|
|
188
278
|
</VSlideGroup>
|
|
@@ -192,6 +282,120 @@ Object.keys(props.customExtensions).forEach((key: any) => {
|
|
|
192
282
|
</div>
|
|
193
283
|
</template>
|
|
194
284
|
|
|
195
|
-
<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
|
+
}
|
|
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;
|
|
196
393
|
|
|
394
|
+
> .v-list-item__overlay {
|
|
395
|
+
opacity: 0;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
197
401
|
</style>
|
|
@@ -4,6 +4,7 @@ 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'
|
|
7
8
|
import { headingLevels } from '@tiptapify/constants/style'
|
|
8
9
|
|
|
9
10
|
const editor = {
|
|
@@ -72,4 +73,27 @@ describe('Toolbar Items', () => {
|
|
|
72
73
|
expect(html).not.toContain('tiptapify-heading-button-1')
|
|
73
74
|
expect(html.match(/<button/g)).toHaveLength(3)
|
|
74
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
|
+
})
|
|
75
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
|
}
|
|
@@ -17,6 +17,15 @@ defineExpose({ open, close })
|
|
|
17
17
|
const emits = defineEmits(['closeDialog'])
|
|
18
18
|
|
|
19
19
|
const showDialog = ref<boolean>(false)
|
|
20
|
+
|
|
21
|
+
const field = { variant: 'outlined', density: 'comfortable' }
|
|
22
|
+
const fieldDefaults = {
|
|
23
|
+
VTextField: field,
|
|
24
|
+
VTextarea: field,
|
|
25
|
+
VSelect: field,
|
|
26
|
+
VAutocomplete: field,
|
|
27
|
+
VCombobox: field,
|
|
28
|
+
}
|
|
20
29
|
const movableHandler = ref(null)
|
|
21
30
|
|
|
22
31
|
function open() {
|
|
@@ -141,48 +150,105 @@ onBeforeUnmount(() => {
|
|
|
141
150
|
|
|
142
151
|
<template>
|
|
143
152
|
<VDialog v-model="showDialog" :max-width="maxWidth" :fullscreen="fullscreen" @click:outside="emitClose">
|
|
144
|
-
<VCard>
|
|
153
|
+
<VCard class="tiptapify-dialog" :class="{ 'tiptapify-dialog--fullscreen': fullscreen }">
|
|
145
154
|
<VCardTitle
|
|
146
155
|
ref="movableHandler"
|
|
147
156
|
class="tiptapify-dialog-header"
|
|
148
157
|
:class="!fullscreen ? 'tiptapify-movable-handler' : ''"
|
|
149
158
|
style="user-select: none;"
|
|
150
159
|
>
|
|
151
|
-
<
|
|
160
|
+
<span class="tiptapify-dialog-title">
|
|
152
161
|
{{ title ?? t(`dialog.${module}.title`) }}
|
|
153
|
-
</
|
|
162
|
+
</span>
|
|
154
163
|
|
|
155
164
|
<VSpacer />
|
|
156
165
|
|
|
157
|
-
<VBtn
|
|
158
|
-
<VIcon size="
|
|
166
|
+
<VBtn class="tiptapify-dialog-close" variant="text" size="32" icon @click="emitClose">
|
|
167
|
+
<VIcon size="20" :icon="`mdiSvg:${mdi.mdiClose}`" />
|
|
159
168
|
</VBtn>
|
|
160
169
|
</VCardTitle>
|
|
161
170
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
171
|
+
<!-- Fields a dialog does not style itself get the light outlined look -->
|
|
172
|
+
<VDefaultsProvider :defaults="fieldDefaults">
|
|
173
|
+
<slot name="content" />
|
|
165
174
|
|
|
166
|
-
|
|
175
|
+
<slot name="actions" />
|
|
176
|
+
</VDefaultsProvider>
|
|
167
177
|
</VCard>
|
|
168
178
|
</VDialog>
|
|
169
179
|
</template>
|
|
170
180
|
|
|
171
181
|
<style lang="scss" scoped>
|
|
182
|
+
/* A light card in the spirit of Flux modals: generous padding, soft corners and shadow, no rules */
|
|
183
|
+
.tiptapify-dialog {
|
|
184
|
+
border-radius: 12px !important;
|
|
185
|
+
box-shadow: 0 20px 48px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06) !important;
|
|
186
|
+
|
|
187
|
+
&.tiptapify-dialog--fullscreen {
|
|
188
|
+
border-radius: 0 !important;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
:deep(.v-card-text) {
|
|
192
|
+
padding: 4px 20px !important;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
:deep(.v-field--variant-outlined) {
|
|
196
|
+
border-radius: 8px;
|
|
197
|
+
|
|
198
|
+
.v-field__outline__start {
|
|
199
|
+
border-start-start-radius: 8px;
|
|
200
|
+
border-end-start-radius: 8px;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.v-field__outline__end {
|
|
204
|
+
border-start-end-radius: 8px;
|
|
205
|
+
border-end-end-radius: 8px;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
:deep(.v-card-actions) {
|
|
210
|
+
gap: 8px;
|
|
211
|
+
padding: 16px 20px 20px !important;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
:deep(.v-card-actions .v-btn),
|
|
215
|
+
:deep(.tiptapify-dialog-actions .v-btn) {
|
|
216
|
+
min-height: 38px;
|
|
217
|
+
padding-inline: 16px;
|
|
218
|
+
border-radius: 8px;
|
|
219
|
+
font-weight: 500;
|
|
220
|
+
letter-spacing: normal;
|
|
221
|
+
text-transform: none;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
172
225
|
.tiptapify-dialog-header {
|
|
173
|
-
align-items: center;
|
|
174
226
|
display: flex;
|
|
227
|
+
align-items: center;
|
|
228
|
+
padding: 16px 12px 12px 20px !important;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.tiptapify-dialog-title {
|
|
232
|
+
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
233
|
+
font-size: 1.125rem;
|
|
234
|
+
font-weight: 600;
|
|
235
|
+
line-height: 1.4;
|
|
236
|
+
white-space: normal;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.tiptapify-dialog-close {
|
|
240
|
+
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
175
241
|
}
|
|
176
242
|
|
|
177
243
|
:deep(.v-overlay__content) {
|
|
178
244
|
position: fixed;
|
|
179
245
|
}
|
|
180
246
|
|
|
181
|
-
.tiptapify-movable-handler
|
|
247
|
+
.tiptapify-movable-handler {
|
|
182
248
|
cursor: move;
|
|
183
249
|
}
|
|
184
250
|
|
|
185
251
|
.tiptapify-dialog-close:hover {
|
|
186
252
|
cursor: pointer;
|
|
187
253
|
}
|
|
188
|
-
</style>
|
|
254
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { editorExtensions } from '@tiptapify/components/editorExtensions'
|
|
3
|
+
|
|
4
|
+
describe('editor extensions', () => {
|
|
5
|
+
it('selects a link on click instead of opening it', () => {
|
|
6
|
+
const link = editorExtensions('', false, []).find(extension => extension.name === 'link')
|
|
7
|
+
|
|
8
|
+
expect(link?.options).toMatchObject({ openOnClick: false, enableClickSelection: true })
|
|
9
|
+
})
|
|
10
|
+
})
|