tiptapify 0.3.2 → 0.3.4
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/README.md +1 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +54477 -48112
- package/dist/tiptapify.umd.js +68 -68
- package/index.d.ts +96 -0
- package/package.json +2 -1
- package/src/components/Footer.vue +12 -2
- package/src/components/ImageBubbleMenu.vue +139 -0
- package/src/components/MenuBubble.vue +60 -7
- package/src/components/SkinScope.test.ts +74 -0
- package/src/components/SkinScope.vue +18 -0
- package/src/components/Tiptapify.skin.test.ts +186 -0
- package/src/components/Tiptapify.vue +285 -74
- package/src/components/Toolbar/Index.test.ts +96 -0
- package/src/components/Toolbar/Index.vue +199 -14
- package/src/components/Toolbar/Items.test.ts +82 -2
- package/src/components/Toolbar/Items.vue +104 -19
- package/src/components/Toolbar/alignment.ts +5 -0
- package/src/components/UI/TiptapifyDialog.test.ts +132 -0
- package/src/components/UI/TiptapifyDialog.vue +128 -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/composables/useTiptapifyConfig.test.ts +25 -2
- package/src/composables/useTiptapifyConfig.ts +21 -2
- package/src/constants/defaults.ts +2 -1
- 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 +72 -0
- package/src/extensions/components/list/bullet/Button.vue +35 -16
- 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 +45 -30
- 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
- package/src/index.test.ts +88 -0
- package/src/index.ts +15 -0
- package/src/skins/__fixtures__/skin-baseline.json +805 -0
- package/src/skins/builtins.ts +81 -0
- package/src/skins/menu.css +87 -0
- package/src/skins/menu.ts +10 -0
- package/src/skins/registry.test.ts +180 -0
- package/src/skins/registry.ts +132 -0
- package/src/skins/tokens.reachability.test.ts +43 -0
- package/src/skins/tokens.ts +92 -0
- package/src/types/editor.ts +99 -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
|
+
})
|
|
@@ -7,6 +7,7 @@ import { computed, inject, PropType, Ref } from 'vue'
|
|
|
7
7
|
import { itemsPropType, toolbarSections } from '@tiptapify/types/toolbarTypes'
|
|
8
8
|
|
|
9
9
|
import { default as toolbarItemsGroups, availableItems } from '@tiptapify/components/Toolbar/items'
|
|
10
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
10
11
|
|
|
11
12
|
const props = defineProps({
|
|
12
13
|
variantField: { type: String as PropType<variantFieldTypes>, default() { return defaults.variantField } },
|
|
@@ -23,20 +24,73 @@ const props = defineProps({
|
|
|
23
24
|
|
|
24
25
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
25
26
|
|
|
27
|
+
// The toolbar shape flag styles the bar: a flat tinted bar when `toolbar` is flat;
|
|
28
|
+
// the classic shape keeps the raised Vuetify toolbar
|
|
29
|
+
const { shape } = useTiptapifyConfig()
|
|
30
|
+
|
|
26
31
|
const propsItems = computed(() => props.items)
|
|
27
32
|
const propsItemsExclude = computed(() => props.itemsExclude)
|
|
28
33
|
|
|
29
34
|
// this prevents from overriding the default items
|
|
30
|
-
let toolbarItems =
|
|
35
|
+
let toolbarItems = Object.values(toolbarItemsGroups).map(section => ({
|
|
36
|
+
...section,
|
|
37
|
+
components: section.components.map(component => ({ ...component })),
|
|
38
|
+
}))
|
|
31
39
|
const availableItemsKeys = Object.keys(availableItems)
|
|
32
40
|
|
|
33
41
|
type toolbarItemsType = { [key: string]: { section: string, group: boolean, components: any[] } }
|
|
34
42
|
if (propsItemsExclude.value) {
|
|
35
43
|
prepareExcludeToolbarItems()
|
|
44
|
+
} else if (Array.isArray(propsItems.value) && propsItems.value.includes('|')) {
|
|
45
|
+
prepareGroupedToolbarItems(propsItems.value as string[])
|
|
36
46
|
} else {
|
|
37
47
|
prepareToolbarItems()
|
|
38
48
|
}
|
|
39
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Items split by `'|'` into groups of their own, whatever sections the buttons come from. A name that is
|
|
52
|
+
* not a built-in item keeps a place for the custom button of that name; editors without it drop the place.
|
|
53
|
+
*/
|
|
54
|
+
function prepareGroupedToolbarItems(items: string[]) {
|
|
55
|
+
const groups: toolbarItemsType[string][] = []
|
|
56
|
+
const newGroup = () => ({ section: `__group_${groups.length}__`, group: true, components: [] as any[] })
|
|
57
|
+
let group = newGroup()
|
|
58
|
+
|
|
59
|
+
const flush = () => {
|
|
60
|
+
if (group.components.length > 0) {
|
|
61
|
+
groups.push(group)
|
|
62
|
+
}
|
|
63
|
+
group = newGroup()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const propsItem of items) {
|
|
67
|
+
if (propsItem === '|') {
|
|
68
|
+
flush()
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (propsItem === '~') {
|
|
73
|
+
flush()
|
|
74
|
+
groups.push({ section: '__spacer__', group: false, components: [] })
|
|
75
|
+
group = newGroup()
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const itemName = propsItem.split(':')[0]
|
|
80
|
+
if (!availableItemsKeys.includes(itemName)) {
|
|
81
|
+
group.components.push({ name: itemName, component: null })
|
|
82
|
+
continue
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const itemSections: toolbarItemsType = {}
|
|
86
|
+
addToolbarItem(itemSections, items, propsItem)
|
|
87
|
+
group.components.push(...Object.values(itemSections).flatMap(section => section.components))
|
|
88
|
+
}
|
|
89
|
+
flush()
|
|
90
|
+
|
|
91
|
+
toolbarItems = groups
|
|
92
|
+
}
|
|
93
|
+
|
|
40
94
|
function prepareToolbarItems() {
|
|
41
95
|
const _toolbarItems: toolbarItemsType = {}
|
|
42
96
|
|
|
@@ -67,6 +121,15 @@ function prepareToolbarItems() {
|
|
|
67
121
|
}
|
|
68
122
|
continue
|
|
69
123
|
}
|
|
124
|
+
// `~` pushes the items after it to the end of the toolbar
|
|
125
|
+
if (propsItem === '~') {
|
|
126
|
+
_toolbarItems['__spacer__'] ??= {
|
|
127
|
+
section: '__spacer__',
|
|
128
|
+
group: false,
|
|
129
|
+
components: []
|
|
130
|
+
}
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
70
133
|
const item = propsItem.split(':')
|
|
71
134
|
if (!availableItemsKeys.includes(item[0])) {
|
|
72
135
|
throw new Error(`The ${propsItem} is unknown extension. Please use one of the following: ${availableItemsKeys.join(', ')}`)
|
|
@@ -105,7 +168,9 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
|
|
|
105
168
|
const itemSection = availableItems[_itemTitle]
|
|
106
169
|
const section = toolbarItemsGroups[itemSection]
|
|
107
170
|
|
|
108
|
-
|
|
171
|
+
// A copy: item options set its props, which must not reach the toolbars of other editors
|
|
172
|
+
const sectionComponent = section?.components.find((component: { name: string }) => component.name === _itemTitle)
|
|
173
|
+
const component = sectionComponent ? { ...sectionComponent } : undefined
|
|
109
174
|
|
|
110
175
|
let itemExists = itemsList.includes(itemTitle)
|
|
111
176
|
itemExists = propsItemsExclude.value ? itemExists : !itemExists
|
|
@@ -145,6 +210,20 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
|
|
|
145
210
|
}
|
|
146
211
|
}
|
|
147
212
|
|
|
213
|
+
// `orderedList:styles` adds the numbering styles dropdown
|
|
214
|
+
if (_itemTitle === 'orderedList' && _itemOptions.includes('styles')) {
|
|
215
|
+
component.props = {
|
|
216
|
+
withStyles: true,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// `align` is the alignment dropdown; `align:left,center,right` limits its options
|
|
221
|
+
if (_itemTitle === 'align' && _itemOptions.length > 0) {
|
|
222
|
+
component.props = {
|
|
223
|
+
alignments: _itemOptions,
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
148
227
|
if (_itemTitle === 'bulletList' && _itemOptions.length > 0) {
|
|
149
228
|
const paragraphIndex = _itemOptions.findIndex((item: any) => item === 'p')
|
|
150
229
|
const withParagraph = paragraphIndex > -1
|
|
@@ -153,6 +232,7 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
|
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
component.props = {
|
|
235
|
+
withStyles: _itemOptions.includes('styles'),
|
|
156
236
|
withDisc: _itemOptions.includes('disc'),
|
|
157
237
|
withCircle: _itemOptions.includes('circle'),
|
|
158
238
|
withSquare: _itemOptions.includes('square'),
|
|
@@ -164,25 +244,46 @@ function addToolbarItem(_toolbarItems: toolbarItemsType, itemsList: Array<any>,
|
|
|
164
244
|
return true
|
|
165
245
|
}
|
|
166
246
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
247
|
+
// A custom button takes the place of the item of its name, joins its section or comes as a section of its own
|
|
248
|
+
for (const customSection of props.customExtensions) {
|
|
249
|
+
const unplaced = []
|
|
250
|
+
for (const component of customSection.components) {
|
|
251
|
+
const place = toolbarItems.flatMap(item => item.components).find(_component => _component.name === component.name)
|
|
252
|
+
if (place) {
|
|
253
|
+
Object.assign(place, component)
|
|
254
|
+
} else {
|
|
255
|
+
unplaced.push(component)
|
|
175
256
|
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (unplaced.length === 0) {
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const existingSection = toolbarItems.find(item => item.section === customSection.section)
|
|
264
|
+
if (existingSection) {
|
|
265
|
+
existingSection.components.push(...unplaced)
|
|
176
266
|
} else {
|
|
177
|
-
toolbarItems.
|
|
267
|
+
const spacerIndex = toolbarItems.findIndex(item => item.section === '__spacer__')
|
|
268
|
+
toolbarItems.splice(spacerIndex > -1 ? spacerIndex : toolbarItems.length, 0, { ...customSection, components: unplaced })
|
|
178
269
|
}
|
|
179
|
-
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
toolbarItems = toolbarItems
|
|
273
|
+
.map(item => ({ ...item, components: item.components.filter(component => component.component) }))
|
|
274
|
+
.filter(item => item.components.length > 0 || ['__spacer__', '__separator__'].includes(item.section))
|
|
180
275
|
|
|
181
276
|
</script>
|
|
182
277
|
|
|
183
278
|
<template>
|
|
184
279
|
<div v-if="editor" class="tiptapify-toolbar">
|
|
185
|
-
<VToolbar
|
|
280
|
+
<VToolbar
|
|
281
|
+
:elevation="shape.toolbar === 'flat' ? 0 : 1"
|
|
282
|
+
:flat="shape.toolbar === 'flat'"
|
|
283
|
+
:theme="theme"
|
|
284
|
+
height="auto"
|
|
285
|
+
:class="`rounded-t-${rounded}`"
|
|
286
|
+
>
|
|
186
287
|
<VSlideGroup v-if="toolbarScrollable">
|
|
187
288
|
<Items :items="toolbarItems" />
|
|
188
289
|
</VSlideGroup>
|
|
@@ -192,6 +293,90 @@ Object.keys(props.customExtensions).forEach((key: any) => {
|
|
|
192
293
|
</div>
|
|
193
294
|
</template>
|
|
194
295
|
|
|
195
|
-
<style lang="scss"
|
|
296
|
+
<style lang="scss">
|
|
297
|
+
/*
|
|
298
|
+
* Flat toolbar: a tinted bar under a hairline. The raised shape paints the bar with the Vuetify
|
|
299
|
+
* toolbar and its own button variants instead, so this rule set is gated on the shape and its
|
|
300
|
+
* fallbacks are the flat shape's base look (the raised values are Vuetify's, not skin rules).
|
|
301
|
+
*/
|
|
302
|
+
.tiptapify-shape-toolbar-flat .tiptapify-toolbar {
|
|
303
|
+
> .v-toolbar {
|
|
304
|
+
background: var(--tf-toolbar-bg, rgba(var(--v-theme-on-surface), 0.03));
|
|
305
|
+
border-bottom: var(--tf-toolbar-border-bottom, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
|
|
306
|
+
color: var(--tf-toolbar-fg, rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity)));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/*
|
|
311
|
+
* Quiet buttons: ghost icon buttons that only get a background on hover, and a neutral "pressed"
|
|
312
|
+
* state instead of the filled primary button. The buttons keep coloring themselves with
|
|
313
|
+
* `color="primary"` when active; this rule maps that to the pressed look. The rules apply iff
|
|
314
|
+
* `buttons` is quiet: through the root shape class in the toolbar, and through the
|
|
315
|
+
* `.tiptapify-quiet-buttons` carrier that the bubble menus carry only in that shape.
|
|
316
|
+
* Tripled class: outranks Vuetify's variant rules (`.v-btn--variant-elevated` background/shadow
|
|
317
|
+
* are plain (0-1-0), so the doubled/tripled class beats them — no `!important` needed for the
|
|
318
|
+
* resting state). The pressed-state block keeps its `!important`s: an active button carries the
|
|
319
|
+
* runtime theme utility class `.bg-primary`/`.text-primary`, which Vuetify generates with
|
|
320
|
+
* `!important`, and the consumer "admin theme" repaints those colored buttons on hover, focus
|
|
321
|
+
* and press (`.bg-primary:focus` and the like) — plain specificity loses against `!important`;
|
|
322
|
+
* this is the admin-theme defense set of the skin layer (spec: Specificity convention).
|
|
323
|
+
*/
|
|
324
|
+
.tiptapify-shape-buttons-quiet .tiptapify-toolbar,
|
|
325
|
+
.tiptapify-quiet-buttons {
|
|
326
|
+
.v-btn.v-btn.v-btn {
|
|
327
|
+
border-radius: var(--tf-button-radius, 6px);
|
|
328
|
+
box-shadow: none;
|
|
329
|
+
background-color: transparent;
|
|
330
|
+
color: var(--tf-button-fg, rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)));
|
|
331
|
+
transition: background-color 0.15s, color 0.15s;
|
|
332
|
+
|
|
333
|
+
> .v-btn__overlay,
|
|
334
|
+
> .v-btn__underlay {
|
|
335
|
+
display: none;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
&:hover,
|
|
339
|
+
&:focus-visible,
|
|
340
|
+
&[aria-expanded='true'] {
|
|
341
|
+
background-color: var(--tf-button-hover-bg, rgba(var(--v-theme-on-surface), 0.07)) !important;
|
|
342
|
+
color: var(--tf-button-hover-fg, rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))) !important;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
&.bg-primary,
|
|
346
|
+
&.text-primary,
|
|
347
|
+
&.tiptapify-btn--active {
|
|
348
|
+
&,
|
|
349
|
+
&:focus,
|
|
350
|
+
&:active {
|
|
351
|
+
background-color: var(--tf-button-active-bg, rgba(var(--v-theme-on-surface), 0.1)) !important;
|
|
352
|
+
color: var(--tf-button-hover-fg, rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity))) !important;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
&:hover,
|
|
356
|
+
&:focus-visible,
|
|
357
|
+
&[aria-expanded='true'] {
|
|
358
|
+
background-color: var(--tf-button-active-hover-bg, rgba(var(--v-theme-on-surface), 0.14)) !important;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
&:focus-visible {
|
|
363
|
+
outline: 2px solid rgba(var(--v-theme-on-surface), 0.25);
|
|
364
|
+
outline-offset: -2px;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* A toolbar button that opens a dropdown: the current choice and a chevron */
|
|
370
|
+
.tiptapify-toolbar .v-btn.tiptapify-dropdown-button {
|
|
371
|
+
min-width: 0;
|
|
372
|
+
padding-inline: 6px 4px;
|
|
373
|
+
|
|
374
|
+
.tiptapify-dropdown-button__chevron {
|
|
375
|
+
margin-inline-start: 2px;
|
|
376
|
+
opacity: 0.7;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
196
379
|
|
|
380
|
+
/* Dropdowns opened from the toolbar: a light card with compact rows of icon + label.
|
|
381
|
+
* The pastel menu skin rules moved to src/skins/menu.css (SkinScope wrapper, spec step 5). */
|
|
197
382
|
</style>
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
-
import { createSSRApp, h, provide, ref } from 'vue'
|
|
2
|
+
import { computed, 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'
|
|
7
8
|
import { headingLevels } from '@tiptapify/constants/style'
|
|
9
|
+
import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
|
|
10
|
+
import { resolveSkin } from '@tiptapify/skins/registry'
|
|
11
|
+
import { variantBtnTypes } from '@tiptapify/types/editor'
|
|
8
12
|
|
|
9
13
|
const editor = {
|
|
10
14
|
instanceId: 1,
|
|
@@ -22,16 +26,31 @@ const items = [
|
|
|
22
26
|
},
|
|
23
27
|
]
|
|
24
28
|
|
|
25
|
-
async function renderToolbar (
|
|
29
|
+
async function renderToolbar (
|
|
30
|
+
toolbarItems: typeof items = items,
|
|
31
|
+
skin: unknown = 'default',
|
|
32
|
+
variantBtn: variantBtnTypes = 'flat',
|
|
33
|
+
) {
|
|
26
34
|
const warnings: string[] = []
|
|
27
35
|
const warn = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => {
|
|
28
36
|
warnings.push(args.map(String).join(' '))
|
|
29
37
|
})
|
|
30
38
|
|
|
39
|
+
// The shape the section markup reads: exactly the chain a real editor provides
|
|
40
|
+
const resolved = resolveSkin(skin as never)
|
|
41
|
+
|
|
31
42
|
const app = createSSRApp({
|
|
32
43
|
setup () {
|
|
33
44
|
provide('tiptapifyEditor', ref(editor))
|
|
34
45
|
provide('tiptapifyI18n', { t: (key: string) => key })
|
|
46
|
+
provide(TIPTAPIFY_CONFIG_KEY, {
|
|
47
|
+
variantBtn: ref(variantBtn),
|
|
48
|
+
variantField: ref('outlined'),
|
|
49
|
+
skin: ref(skin),
|
|
50
|
+
skinName: computed(() => resolved.name),
|
|
51
|
+
tokens: computed(() => resolved.tokens),
|
|
52
|
+
shape: computed(() => resolved.shape),
|
|
53
|
+
})
|
|
35
54
|
|
|
36
55
|
return () => h(Items, { items: toolbarItems })
|
|
37
56
|
},
|
|
@@ -44,6 +63,13 @@ async function renderToolbar (toolbarItems: typeof items = items) {
|
|
|
44
63
|
return { html, warnings }
|
|
45
64
|
}
|
|
46
65
|
|
|
66
|
+
// The opening tag of the section's group element
|
|
67
|
+
function groupTag (html: string): string {
|
|
68
|
+
const match = html.match(/<div[^>]*class="[^"]*tiptapify-toolbar-group[^"]*"[^>]*>/)
|
|
69
|
+
|
|
70
|
+
return match ? match[0] : ''
|
|
71
|
+
}
|
|
72
|
+
|
|
47
73
|
describe('Toolbar Items', () => {
|
|
48
74
|
afterEach(() => {
|
|
49
75
|
headingLevels.value = [1, 2, 3, 4, 5, 6]
|
|
@@ -72,4 +98,58 @@ describe('Toolbar Items', () => {
|
|
|
72
98
|
expect(html).not.toContain('tiptapify-heading-button-1')
|
|
73
99
|
expect(html.match(/<button/g)).toHaveLength(3)
|
|
74
100
|
})
|
|
101
|
+
|
|
102
|
+
it('renders a spacer that pushes the next items to the end', async () => {
|
|
103
|
+
const { html } = await renderToolbar([
|
|
104
|
+
items[0],
|
|
105
|
+
{ section: '__spacer__', group: false, components: [] },
|
|
106
|
+
{ section: 'actions', group: true, components: [{ name: 'heading', component: HeadingButton }] },
|
|
107
|
+
])
|
|
108
|
+
|
|
109
|
+
expect(html).toContain('tiptapify-toolbar-spacer')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('renders the alignment dropdown with the chosen alignments', async () => {
|
|
113
|
+
const { html } = await renderToolbar([
|
|
114
|
+
{
|
|
115
|
+
section: 'alignment',
|
|
116
|
+
group: true,
|
|
117
|
+
components: [{ name: 'align', component: AlignmentMenuButton, props: { alignments: ['left', 'center'] } }],
|
|
118
|
+
},
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
expect(html).toContain('tiptapify-align-button-1')
|
|
122
|
+
expect(html).toContain('tiptapify-dropdown-button__chevron')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('renders a grouped section as a VBtnGroup under groups: box', async () => {
|
|
126
|
+
const tag = groupTag(await renderToolbar(items, 'default').then((r) => r.html))
|
|
127
|
+
|
|
128
|
+
expect(tag).toContain('tiptapify-toolbar-group')
|
|
129
|
+
expect(tag).toContain('v-btn-group')
|
|
130
|
+
expect(tag).toContain('elevation-4')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('keeps the 0.3.2 variantBtn elevation class conditional', async () => {
|
|
134
|
+
const flat = groupTag(await renderToolbar(items, 'default', 'flat').then((r) => r.html))
|
|
135
|
+
expect(flat).toContain('v-btn-group')
|
|
136
|
+
expect(flat).not.toContain('tiptapify-btn-group--elevated')
|
|
137
|
+
|
|
138
|
+
const elevated = groupTag(await renderToolbar(items, 'default', 'elevated').then((r) => r.html))
|
|
139
|
+
expect(elevated).toContain('tiptapify-btn-group--elevated')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('renders a grouped section as a plain div under groups: flat', async () => {
|
|
143
|
+
const tag = groupTag(await renderToolbar(items, 'pastel').then((r) => r.html))
|
|
144
|
+
|
|
145
|
+
expect(tag).toContain('tiptapify-toolbar-group')
|
|
146
|
+
expect(tag).not.toContain('v-btn-group')
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('an inline groups: box flag on a pastel-base skin gets the VBtnGroup markup', async () => {
|
|
150
|
+
const tag = groupTag(await renderToolbar(items, { extends: 'pastel', groups: 'box' }).then((r) => r.html))
|
|
151
|
+
|
|
152
|
+
expect(tag).toContain('tiptapify-toolbar-group')
|
|
153
|
+
expect(tag).toContain('v-btn-group')
|
|
154
|
+
})
|
|
75
155
|
})
|
|
@@ -1,30 +1,47 @@
|
|
|
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'
|
|
7
6
|
|
|
7
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
|
+
|
|
8
9
|
defineProps({
|
|
9
10
|
items: { type: Array as PropType<toolbarSections>, default() { return {} } },
|
|
10
11
|
})
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
// The section markup follows the `groups` shape flag: 'box' renders the 0.3.2 VBtnGroup
|
|
14
|
+
// (joined buttons in a raised box, the tokens below paint it), 'flat' keeps the plain div.
|
|
15
|
+
// `variantBtn` carries the 0.3.2 elevation-override class onto the group.
|
|
16
|
+
const { shape, variantBtn } = useTiptapifyConfig()
|
|
13
17
|
|
|
14
18
|
</script>
|
|
15
19
|
|
|
16
20
|
<template>
|
|
17
|
-
<VToolbarItems class="
|
|
21
|
+
<VToolbarItems class="tiptapify-toolbar-items">
|
|
18
22
|
<template v-for="item in items" :key="item.section">
|
|
19
23
|
<template v-if="item.section === '__separator__'">
|
|
20
24
|
<div class="menu-divider" />
|
|
21
25
|
</template>
|
|
26
|
+
<template v-else-if="item.section === '__spacer__'">
|
|
27
|
+
<div class="tiptapify-toolbar-spacer" />
|
|
28
|
+
</template>
|
|
22
29
|
<template v-else>
|
|
23
|
-
<VBtnGroup
|
|
30
|
+
<VBtnGroup
|
|
31
|
+
v-if="item.group && shape.groups === 'box'"
|
|
32
|
+
class="tiptapify-toolbar-group"
|
|
33
|
+
elevation="4"
|
|
34
|
+
:class="{ 'tiptapify-btn-group--elevated': variantBtn === 'elevated' }"
|
|
35
|
+
>
|
|
24
36
|
<template v-for="sectionItem in item.components" :key="sectionItem.name">
|
|
25
37
|
<component :is="sectionItem.component" v-bind="sectionItem.props ?? {}" />
|
|
26
38
|
</template>
|
|
27
39
|
</VBtnGroup>
|
|
40
|
+
<div v-else-if="item.group" class="tiptapify-toolbar-group">
|
|
41
|
+
<template v-for="sectionItem in item.components" :key="sectionItem.name">
|
|
42
|
+
<component :is="sectionItem.component" v-bind="sectionItem.props ?? {}" />
|
|
43
|
+
</template>
|
|
44
|
+
</div>
|
|
28
45
|
<template v-else>
|
|
29
46
|
<component
|
|
30
47
|
:is="sectionItem.component"
|
|
@@ -40,35 +57,103 @@ const { variantBtn } = useTiptapifyConfig()
|
|
|
40
57
|
</template>
|
|
41
58
|
|
|
42
59
|
<style lang="scss" scoped>
|
|
43
|
-
|
|
60
|
+
/*
|
|
61
|
+
* Skin rules for the bar, the group boxes and the dividers (spec 2026-10-18: single rules
|
|
62
|
+
* reading `var(--tf-*)`). Base = the classic look, the fixture-canonical fallbacks; the
|
|
63
|
+
* shape gates (`toolbar: flat`, `groups: flat`) carry the same tokens with the shape's
|
|
64
|
+
* own base as fallback, so a shape flag without tokens paints the 0.3.3 look.
|
|
65
|
+
*/
|
|
66
|
+
.tiptapify-toolbar-items {
|
|
67
|
+
/* Vuetify sizes .v-toolbar-items to its content; grow it to the full toolbar width,
|
|
68
|
+
* otherwise the `~` spacer has no free space to expand into. */
|
|
69
|
+
flex-grow: 1;
|
|
70
|
+
flex-wrap: wrap;
|
|
71
|
+
align-items: center;
|
|
72
|
+
row-gap: var(--tf-items-row-gap, 5px);
|
|
73
|
+
/* Classic: no column gap (Vuetify's `normal`); the flat gate sets the token's own base */
|
|
74
|
+
column-gap: var(--tf-items-gap, normal);
|
|
75
|
+
padding: var(--tf-items-padding, 12px 16px);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.tiptapify-toolbar-group {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
|
|
82
|
+
/* The classic group: a raised box around the section's buttons, Vuetify elevation 2 (fixture-canonical) */
|
|
83
|
+
height: var(--tf-group-height, 32px);
|
|
84
|
+
border-radius: var(--tf-group-radius, 4px);
|
|
85
|
+
background: var(--tf-group-bg, rgb(var(--v-theme-surface)));
|
|
44
86
|
box-shadow:
|
|
87
|
+
var(--tf-group-shadow,
|
|
45
88
|
0 3px 1px -2px var(--v-shadow-key-umbra-opacity, rgba(0, 0, 0, 0.2)),
|
|
46
89
|
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));
|
|
90
|
+
0 1px 5px 0 var(--v-shadow-key-ambient-opacity, rgba(0, 0, 0, 0.12)));
|
|
48
91
|
}
|
|
49
92
|
|
|
50
|
-
|
|
51
|
-
|
|
93
|
+
.menu-divider {
|
|
94
|
+
margin: 0 4px;
|
|
95
|
+
width: var(--tf-divider-width, 0px);
|
|
96
|
+
height: var(--tf-divider-height, 0px);
|
|
97
|
+
background: var(--tf-divider-color, transparent);
|
|
52
98
|
}
|
|
53
99
|
|
|
54
|
-
.menu-
|
|
55
|
-
|
|
100
|
+
.menu-divider:nth-last-child(1),
|
|
101
|
+
.menu-divider:has(+ .tiptapify-toolbar-spacer) {
|
|
102
|
+
display: none;
|
|
56
103
|
}
|
|
57
104
|
|
|
58
|
-
.
|
|
59
|
-
|
|
105
|
+
.tiptapify-toolbar-spacer {
|
|
106
|
+
flex: 1 0 0;
|
|
60
107
|
}
|
|
61
108
|
|
|
62
|
-
|
|
63
|
-
|
|
109
|
+
/* Flat toolbar: compact padding and tighter gaps in the flat bar */
|
|
110
|
+
.tiptapify-shape-toolbar-flat {
|
|
111
|
+
.tiptapify-toolbar-items {
|
|
112
|
+
column-gap: var(--tf-items-gap, 6px);
|
|
113
|
+
row-gap: var(--tf-items-row-gap, 4px);
|
|
114
|
+
padding: var(--tf-items-padding, 6px 8px);
|
|
115
|
+
}
|
|
64
116
|
}
|
|
65
117
|
|
|
66
|
-
|
|
67
|
-
|
|
118
|
+
/* Box groups: the section is a VBtnGroup (0.3.2 structure restored). The base token rules
|
|
119
|
+
* above keep painting the box: the scoped `.tiptapify-toolbar-group` (class + scope
|
|
120
|
+
* attribute) out-specifies Vuetify's single-class `.v-btn-group` background, radius and
|
|
121
|
+
* zero-shadow rules, and `.elevation-4`. Two 0.3.2 pieces are what its own CSS fights:
|
|
122
|
+
* - `.v-btn-group--density-default.v-btn-group` pins 48px at equal specificity, decided by
|
|
123
|
+
* stylesheet order — 0.3.2 needed `:deep(.v-btn-group){ height: 32px !important }`, kept
|
|
124
|
+
* here as the tokenized `--tf-group-height` consumer (the 0.3.2 set demands the
|
|
125
|
+
* !important; the token contract stays honest, no new tokens);
|
|
126
|
+
* - `.menu-button { margin: 0 1px }` re-added for CSS-set parity with 0.3.2: inert there
|
|
127
|
+
* too (no toolbar element carries the class), and the buttons joining inside the box is
|
|
128
|
+
* native VBtnGroup (`border-radius: 0` + corner `inherit`), needing no rule of ours.
|
|
129
|
+
* The 0.3.2 `.tiptapify-btn-group--elevated` shadow override is NOT re-added: the token
|
|
130
|
+
* fallback is exactly that elevation-2 triple, and the scoped base rule beats elevation-4. */
|
|
131
|
+
.tiptapify-shape-groups-box {
|
|
132
|
+
.tiptapify-toolbar-group {
|
|
133
|
+
height: var(--tf-group-height, 32px) !important;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.menu-button {
|
|
137
|
+
margin: 0 1px;
|
|
138
|
+
}
|
|
68
139
|
}
|
|
69
140
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
141
|
+
/* Flat groups: sections become plain runs of buttons with hairline dividers between them;
|
|
142
|
+
* the box shape keeps the raised group defaults above untouched. */
|
|
143
|
+
.tiptapify-shape-groups-flat {
|
|
144
|
+
.tiptapify-toolbar-group {
|
|
145
|
+
gap: var(--tf-group-gap, 2px);
|
|
146
|
+
height: var(--tf-group-height, auto);
|
|
147
|
+
border-radius: var(--tf-group-radius, 0px);
|
|
148
|
+
background: var(--tf-group-bg, transparent);
|
|
149
|
+
box-shadow: var(--tf-group-shadow, none);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.menu-divider {
|
|
153
|
+
margin: 0;
|
|
154
|
+
width: var(--tf-divider-width, 1px);
|
|
155
|
+
height: var(--tf-divider-height, 20px);
|
|
156
|
+
background: var(--tf-divider-color, rgba(var(--v-border-color), var(--v-border-opacity)));
|
|
157
|
+
}
|
|
73
158
|
}
|
|
74
159
|
</style>
|