tiptapify 0.3.3 → 0.3.5
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 +15909 -15553
- package/dist/tiptapify.umd.js +55 -55
- package/index.d.ts +95 -0
- package/package.json +2 -1
- package/src/components/ImageBubbleMenu.vue +16 -7
- package/src/components/MenuBubble.vue +45 -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 +115 -21
- package/src/components/Toolbar/Index.vue +43 -62
- package/src/components/Toolbar/Items.test.ts +58 -2
- package/src/components/Toolbar/Items.vue +93 -7
- package/src/components/UI/TiptapifyDialog.test.ts +132 -0
- package/src/components/UI/TiptapifyDialog.vue +90 -40
- 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/alignment/menu/Button.vue +21 -17
- package/src/extensions/components/list/bullet/Button.vue +20 -16
- package/src/extensions/components/list/styles/ListStyleButton.vue +25 -25
- package/src/extensions/components/style/heading/Button.vue +33 -29
- 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
- package/src/utils/validateHref.test.ts +25 -0
- package/src/utils/validateHref.ts +17 -13
package/index.d.ts
CHANGED
|
@@ -67,6 +67,101 @@ import * as mdi from '@mdi/js'
|
|
|
67
67
|
|
|
68
68
|
export type TiptapifyFooterAlignment = 'start' | 'center' | 'end'
|
|
69
69
|
|
|
70
|
+
export type TiptapifySkin = 'default' | 'pastel'
|
|
71
|
+
|
|
72
|
+
// Structural variants a token cannot express (component markup, vuetify prop bindings)
|
|
73
|
+
export interface TiptapifyShape {
|
|
74
|
+
toolbar?: 'raised' | 'flat'
|
|
75
|
+
groups?: 'box' | 'flat'
|
|
76
|
+
buttons?: 'vuetify' | 'quiet'
|
|
77
|
+
bubble?: 'card' | 'flat'
|
|
78
|
+
dialog?: 'classic' | 'soft'
|
|
79
|
+
menu?: 'vuetify' | 'soft'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The shape after resolution: every key set (missing keys fall back to classic)
|
|
83
|
+
export type ResolvedShape = Required<TiptapifyShape>
|
|
84
|
+
|
|
85
|
+
export interface TiptapifySkinColors {
|
|
86
|
+
toolbarBg: string
|
|
87
|
+
toolbarBorderBottom: string
|
|
88
|
+
toolbarFg: string
|
|
89
|
+
groupBg: string
|
|
90
|
+
groupShadow: string
|
|
91
|
+
dividerColor: string
|
|
92
|
+
buttonFg: string
|
|
93
|
+
buttonHoverBg: string
|
|
94
|
+
buttonHoverFg: string
|
|
95
|
+
buttonActiveBg: string
|
|
96
|
+
buttonActiveHoverBg: string
|
|
97
|
+
bubbleBg: string
|
|
98
|
+
bubbleBorder: string
|
|
99
|
+
bubbleShadow: string
|
|
100
|
+
menuBorder: string
|
|
101
|
+
menuShadow: string
|
|
102
|
+
menuItemActiveBg: string
|
|
103
|
+
dialogShadow: string
|
|
104
|
+
frameBg: string
|
|
105
|
+
frameShadow: string
|
|
106
|
+
frameBorder: string
|
|
107
|
+
imageBg: string
|
|
108
|
+
imageOutline: string
|
|
109
|
+
imageOutlineHover: string
|
|
110
|
+
handleBg: string
|
|
111
|
+
handleBgHover: string
|
|
112
|
+
handleBorder: string
|
|
113
|
+
handleShadow: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface TiptapifySkinMetrics {
|
|
117
|
+
itemsPadding: string
|
|
118
|
+
itemsGap: string
|
|
119
|
+
itemsRowGap: string
|
|
120
|
+
groupGap: string
|
|
121
|
+
groupHeight: string
|
|
122
|
+
groupRadius: string
|
|
123
|
+
dividerWidth: string
|
|
124
|
+
dividerHeight: string
|
|
125
|
+
buttonRadius: string
|
|
126
|
+
bubbleRadius: string
|
|
127
|
+
bubblePadding: string
|
|
128
|
+
bubbleGap: string
|
|
129
|
+
menuListPadding: string
|
|
130
|
+
menuRadius: string
|
|
131
|
+
menuItemRadius: string
|
|
132
|
+
menuItemPadding: string
|
|
133
|
+
menuItemMinHeight: string
|
|
134
|
+
dialogRadius: string
|
|
135
|
+
dialogHeaderPadding: string
|
|
136
|
+
dialogContentPadding: string
|
|
137
|
+
dialogActionsPadding: string
|
|
138
|
+
dialogTitleSize: string
|
|
139
|
+
dialogTitleWeight: string
|
|
140
|
+
dialogFieldRadius: string
|
|
141
|
+
dialogActionBtnMinHeight: string
|
|
142
|
+
dialogActionBtnRadius: string
|
|
143
|
+
dialogGridGap: string
|
|
144
|
+
imageRadius: string
|
|
145
|
+
handleRadius: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A skin as data: token overrides + shape flags, inheriting from a registered skin
|
|
149
|
+
export type TiptapifySkinConfig = TiptapifyShape & {
|
|
150
|
+
name?: string
|
|
151
|
+
// registered skin name; implicit 'default'
|
|
152
|
+
extends?: string
|
|
153
|
+
colors?: Partial<TiptapifySkinColors>
|
|
154
|
+
metrics?: Partial<TiptapifySkinMetrics>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// The `skin` prop: a registered name (string) or an inline object
|
|
158
|
+
export type TiptapifySkinInput = string | TiptapifySkinConfig
|
|
159
|
+
|
|
160
|
+
export declare function registerSkin(name: string, skin: TiptapifySkinConfig): void
|
|
161
|
+
|
|
162
|
+
// Identity for typed const definitions; the object never enters the registry
|
|
163
|
+
export declare function defineSkin(skin: TiptapifySkinConfig): TiptapifySkinConfig
|
|
164
|
+
|
|
70
165
|
export interface TiptapifyOptions {
|
|
71
166
|
i18n?: string
|
|
72
167
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tiptapify",
|
|
3
3
|
"types": "./index.d.ts",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.5",
|
|
5
5
|
"description": "Tiptap3 editor with Vuetify3 menu implementation",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"test:watch": "vitest",
|
|
35
35
|
"build:charmap": "tsx build-charmap.ts",
|
|
36
36
|
"build:emojis": "tsx build-emojis.ts",
|
|
37
|
+
"record:skin-baseline": "tsx scripts/record-skin-baseline.ts",
|
|
37
38
|
"docs:dev": "pnpm --filter tiptapify-docs docs:dev",
|
|
38
39
|
"docs:build": "pnpm --filter tiptapify-docs docs:build",
|
|
39
40
|
"docs:preview": "pnpm --filter tiptapify-docs docs:preview"
|
|
@@ -8,10 +8,14 @@ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
|
8
8
|
import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
|
|
9
9
|
import { inject, Ref, ref } from 'vue'
|
|
10
10
|
import { ComposerTranslation } from 'vue-i18n'
|
|
11
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
11
12
|
|
|
12
13
|
// The toolbar on a selected image: float toggles (or, for a slide, its place in the slider), download, edit, removal
|
|
13
14
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
14
15
|
|
|
16
|
+
// The image menu follows the buttons shape flag: a quiet carrier in the quiet shape
|
|
17
|
+
const { shape } = useTiptapifyConfig()
|
|
18
|
+
|
|
15
19
|
// The menu follows its text or image when the editor scrolls inside its own box; it is created once the
|
|
16
20
|
// box is there, as the menu reads its options only when it is created
|
|
17
21
|
const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
|
|
@@ -62,7 +66,7 @@ function download() {
|
|
|
62
66
|
:options="{ placement: 'top', offset: -52, flip: false, scrollTarget }"
|
|
63
67
|
:should-show="isImageSelected"
|
|
64
68
|
>
|
|
65
|
-
<div class="tiptapify-image-menu tiptapify-quiet-buttons">
|
|
69
|
+
<div class="tiptapify-image-menu" :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }">
|
|
66
70
|
<template v-if="isSlide()">
|
|
67
71
|
<VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(-1)" @click="editor.chain().focus().moveImage(-1).run()">
|
|
68
72
|
<Tooltip :label="t('media.image_toolbar.move_back')" />
|
|
@@ -110,15 +114,20 @@ function download() {
|
|
|
110
114
|
</template>
|
|
111
115
|
|
|
112
116
|
<style scoped lang="scss">
|
|
117
|
+
/*
|
|
118
|
+
* The image menu follows the bubble tokens: one rule, the classic shape's base as fallback;
|
|
119
|
+
* the quiet shape paints its border/radius/shadow from the same tokens.
|
|
120
|
+
* The internal divider is this menu's own layout, not the toolbar divider surface.
|
|
121
|
+
*/
|
|
113
122
|
.tiptapify-image-menu {
|
|
114
123
|
display: flex;
|
|
115
124
|
align-items: center;
|
|
116
|
-
gap: 2px;
|
|
117
|
-
padding: 4px;
|
|
118
|
-
border:
|
|
119
|
-
border-radius:
|
|
120
|
-
background: rgb(var(--v-theme-surface));
|
|
121
|
-
box-shadow:
|
|
125
|
+
gap: var(--tf-bubble-gap, 2px);
|
|
126
|
+
padding: var(--tf-bubble-padding, 4px);
|
|
127
|
+
border: var(--tf-bubble-border, none);
|
|
128
|
+
border-radius: var(--tf-bubble-radius, 6px);
|
|
129
|
+
background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
|
|
130
|
+
box-shadow: var(--tf-bubble-shadow, 4px 4px 20px var(--dark-gray));
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
.tiptapify-image-menu__divider {
|
|
@@ -4,6 +4,7 @@ import { computed, inject, Ref, ref } from 'vue'
|
|
|
4
4
|
import { BubbleMenu } from '@tiptap/vue-3/menus'
|
|
5
5
|
import { NodeSelection } from '@tiptap/pm/state'
|
|
6
6
|
import * as mdi from '@mdi/js'
|
|
7
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
7
8
|
|
|
8
9
|
defineProps({
|
|
9
10
|
variant: { type: String, default () { return 'flat' } },
|
|
@@ -12,6 +13,10 @@ defineProps({
|
|
|
12
13
|
|
|
13
14
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
14
15
|
|
|
16
|
+
// The bubble shape picks the markup: a flat row of buttons vs the classic card; the quiet
|
|
17
|
+
// buttons carrier rides the buttons shape flag
|
|
18
|
+
const { shape } = useTiptapifyConfig()
|
|
19
|
+
|
|
15
20
|
// The menu follows its text or image when the editor scrolls inside its own box; it is created once the
|
|
16
21
|
// box is there, as the menu reads its options only when it is created
|
|
17
22
|
const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
|
|
@@ -109,7 +114,11 @@ const items = ref([
|
|
|
109
114
|
return (from !== to) && !isAllSelected
|
|
110
115
|
}"
|
|
111
116
|
>
|
|
112
|
-
<div
|
|
117
|
+
<div
|
|
118
|
+
v-if="shape.bubble === 'flat'"
|
|
119
|
+
class="tiptapify-bubble-menu"
|
|
120
|
+
:class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }"
|
|
121
|
+
>
|
|
113
122
|
<VBtn
|
|
114
123
|
v-for="(item, key) in items"
|
|
115
124
|
:key="key"
|
|
@@ -123,18 +132,47 @@ const items = ref([
|
|
|
123
132
|
<VIcon :icon="item.icon" size="20" />
|
|
124
133
|
</VBtn>
|
|
125
134
|
</div>
|
|
135
|
+
|
|
136
|
+
<!-- The classic bubble menu: a small card with a divided group of compact buttons -->
|
|
137
|
+
<div v-else class="bubble-menu">
|
|
138
|
+
<VCard>
|
|
139
|
+
<VCardText class="pa-0">
|
|
140
|
+
<VToolbar :theme="theme" density="compact" height="auto" class="p-0">
|
|
141
|
+
<VToolbarItems>
|
|
142
|
+
<VBtnGroup divided density="compact">
|
|
143
|
+
<VBtn v-for="(item, key) in items" :key="key" v-bind="item.props" size="x-small" @click="item.click">
|
|
144
|
+
<VIcon :icon="item.icon" size="20" />
|
|
145
|
+
</VBtn>
|
|
146
|
+
</VBtnGroup>
|
|
147
|
+
</VToolbarItems>
|
|
148
|
+
</VToolbar>
|
|
149
|
+
</VCardText>
|
|
150
|
+
</VCard>
|
|
151
|
+
</div>
|
|
126
152
|
</BubbleMenu>
|
|
127
153
|
</template>
|
|
128
154
|
|
|
129
155
|
<style scoped lang="scss">
|
|
156
|
+
/*
|
|
157
|
+
* Bubble skin rules: one rule per shape branch, reading `var(--tf-*)`.
|
|
158
|
+
* The template branch is the shape gate (`bubble: card` renders the classic card,
|
|
159
|
+
* `bubble: flat` the quiet row), so each rule carries its shape's base as fallback.
|
|
160
|
+
* The classic card's background/border/padding/gap are VCard/VToolbar layout (not
|
|
161
|
+
* token dimensions); the flat row paints them from tokens.
|
|
162
|
+
*/
|
|
163
|
+
.bubble-menu {
|
|
164
|
+
border-radius: var(--tf-bubble-radius, 6px);
|
|
165
|
+
box-shadow: var(--tf-bubble-shadow, 4px 4px 20px var(--dark-gray));
|
|
166
|
+
}
|
|
167
|
+
|
|
130
168
|
.tiptapify-bubble-menu {
|
|
131
169
|
display: flex;
|
|
132
170
|
align-items: center;
|
|
133
|
-
gap: 2px;
|
|
134
|
-
padding: 4px;
|
|
135
|
-
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
136
|
-
border-radius: 10px;
|
|
137
|
-
background: rgb(var(--v-theme-surface));
|
|
138
|
-
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
|
171
|
+
gap: var(--tf-bubble-gap, 2px);
|
|
172
|
+
padding: var(--tf-bubble-padding, 4px);
|
|
173
|
+
border: var(--tf-bubble-border, 1px solid rgba(var(--v-border-color), var(--v-border-opacity)));
|
|
174
|
+
border-radius: var(--tf-bubble-radius, 10px);
|
|
175
|
+
background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
|
|
176
|
+
box-shadow: var(--tf-bubble-shadow, 0 6px 20px rgba(0, 0, 0, 0.12));
|
|
139
177
|
}
|
|
140
178
|
</style>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { computed, createSSRApp, h, provide, ref } from 'vue'
|
|
3
|
+
import { renderToString } from 'vue/server-renderer'
|
|
4
|
+
import { createVuetify } from 'vuetify'
|
|
5
|
+
import SkinScope from '@tiptapify/components/SkinScope.vue'
|
|
6
|
+
import { menuContentClass } from '@tiptapify/skins/menu'
|
|
7
|
+
import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
|
|
8
|
+
import { resolveSkin } from '@tiptapify/skins/registry'
|
|
9
|
+
|
|
10
|
+
// Menus teleport out of the editor DOM, so SkinScope re-anchors the skin tokens
|
|
11
|
+
// on the menu content: a plain div with the provided `--tf-*` record inline.
|
|
12
|
+
async function renderScope (tokens: Record<string, string> | null) {
|
|
13
|
+
const app = createSSRApp({
|
|
14
|
+
setup () {
|
|
15
|
+
if (tokens) {
|
|
16
|
+
provide(TIPTAPIFY_CONFIG_KEY, {
|
|
17
|
+
variantBtn: ref('flat'),
|
|
18
|
+
variantField: ref('outlined'),
|
|
19
|
+
skin: ref('pastel'),
|
|
20
|
+
skinName: computed(() => 'pastel'),
|
|
21
|
+
tokens: computed(() => tokens),
|
|
22
|
+
shape: computed(() => resolveSkin('pastel').shape),
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
return () => h(SkinScope, null, { default: () => h('div', { id: 'slot-child' }, 'content') })
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
app.use(createVuetify())
|
|
29
|
+
|
|
30
|
+
return renderToString(app)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('SkinScope', () => {
|
|
34
|
+
it('renders div.tiptapify-skin-scope with the provided --tf-* tokens inline', async () => {
|
|
35
|
+
const html = await renderScope({ '--tf-menu-list-padding': '4px', '--tf-menu-radius': '8px' })
|
|
36
|
+
|
|
37
|
+
expect(html).toContain('class="tiptapify-skin-scope"')
|
|
38
|
+
expect(html).toContain('--tf-menu-list-padding:4px')
|
|
39
|
+
expect(html).toContain('--tf-menu-radius:8px')
|
|
40
|
+
expect(html).toContain('id="slot-child"')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('renders the scope div through the full pastel resolution, slot content inside', async () => {
|
|
44
|
+
const html = await renderScope(resolveSkin('pastel').tokens)
|
|
45
|
+
|
|
46
|
+
expect(html).toMatch(/^<div class="tiptapify-skin-scope" style="--tf-/)
|
|
47
|
+
expect(html).toContain('--tf-menu-list-padding:4px')
|
|
48
|
+
expect(html).toContain('--tf-dialog-radius:12px')
|
|
49
|
+
// The slot renders inside the scope (SSR wraps slot output in hydration comments)
|
|
50
|
+
expect(html).toContain('<div id="slot-child">content</div><!--]--></div>')
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('emits no inline vars without a provider (default skin emits none, css fallbacks carry the base)', async () => {
|
|
54
|
+
const html = await renderScope(null)
|
|
55
|
+
|
|
56
|
+
expect(html).toContain('class="tiptapify-skin-scope"')
|
|
57
|
+
expect(html).not.toContain('--tf-')
|
|
58
|
+
expect(html).toContain('id="slot-child"')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('menuContentClass', () => {
|
|
63
|
+
it('carries the soft menu class for the soft menu shape and the plain base otherwise', () => {
|
|
64
|
+
expect(menuContentClass(true)).toBe('tiptapify-menu tiptapify-menu--soft')
|
|
65
|
+
expect(menuContentClass(false)).toBe('tiptapify-menu')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('the soft carrier rides the menu shape flag, not the skin name: a custom skin extending pastel keeps it', () => {
|
|
69
|
+
expect(menuContentClass(resolveSkin('pastel').shape.menu === 'soft')).toBe('tiptapify-menu tiptapify-menu--soft')
|
|
70
|
+
expect(menuContentClass(resolveSkin({ extends: 'pastel' }).shape.menu === 'soft')).toBe('tiptapify-menu tiptapify-menu--soft')
|
|
71
|
+
expect(menuContentClass(resolveSkin('default').shape.menu === 'soft')).toBe('tiptapify-menu')
|
|
72
|
+
expect(menuContentClass(resolveSkin({ menu: 'soft' }).shape.menu === 'soft')).toBe('tiptapify-menu tiptapify-menu--soft')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
|
|
3
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* Menus teleport out of the editor DOM, so `:style` cannot ride along with a
|
|
7
|
+
* VMenu `content-class`: every tiptapify menu wraps its content in this scope
|
|
8
|
+
* and the skin tokens land on it (spec: Token delivery). The CSS that skins
|
|
9
|
+
* the menus hangs off `.tiptapify-skin-scope` (src/skins/menu.css).
|
|
10
|
+
*/
|
|
11
|
+
const { tokens } = useTiptapifyConfig()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div class="tiptapify-skin-scope" :style="tokens">
|
|
16
|
+
<slot />
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createApp, createSSRApp, h, nextTick, ref, Ref } from 'vue'
|
|
3
|
+
import { renderToString } from 'vue/server-renderer'
|
|
4
|
+
import { createVuetify } from 'vuetify'
|
|
5
|
+
import { createI18n } from 'vue-i18n'
|
|
6
|
+
import Tiptapify from '@tiptapify/components/Tiptapify.vue'
|
|
7
|
+
import { messages } from '@tiptapify/i18n'
|
|
8
|
+
|
|
9
|
+
function withPlugins (app: { use: (plugin: never) => unknown }) {
|
|
10
|
+
app.use(createVuetify() as never)
|
|
11
|
+
app.use(createI18n({ legacy: false, locale: 'en', fallbackLocale: 'en', messages }) as never)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function renderShell (skin: unknown): Promise<string> {
|
|
15
|
+
const app = createSSRApp({ setup: () => () => h(Tiptapify, { skin }) })
|
|
16
|
+
withPlugins(app)
|
|
17
|
+
|
|
18
|
+
return renderToString(app)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The opening tag of the editor root div, so assertions see the skin class and the token style
|
|
22
|
+
function rootTag (html: string): string {
|
|
23
|
+
const start = html.indexOf('tiptapify-editor-')
|
|
24
|
+
|
|
25
|
+
return html.slice(0, html.indexOf('>', start) + 1)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function mountEditor (skinRef: Ref<unknown>) {
|
|
29
|
+
const app = createApp({ setup: () => () => h(Tiptapify, { skin: skinRef.value }) })
|
|
30
|
+
withPlugins(app)
|
|
31
|
+
const host = document.createElement('div')
|
|
32
|
+
document.body.appendChild(host)
|
|
33
|
+
app.mount(host)
|
|
34
|
+
await nextTick()
|
|
35
|
+
await nextTick()
|
|
36
|
+
|
|
37
|
+
return { app, host }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Tiptapify skin resolution', () => {
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.restoreAllMocks()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders the default skin class and emits no token vars', async () => {
|
|
46
|
+
const html = await renderShell('default')
|
|
47
|
+
|
|
48
|
+
expect(rootTag(html)).toContain('tiptapify-skin-default')
|
|
49
|
+
expect(rootTag(html)).not.toContain('--tf-')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('renders the pastel skin class and emits its token overrides', async () => {
|
|
53
|
+
const html = await renderShell('pastel')
|
|
54
|
+
|
|
55
|
+
expect(rootTag(html)).toContain('tiptapify-skin-pastel')
|
|
56
|
+
expect(rootTag(html)).toContain('--tf-toolbar-bg:rgba(var(--v-theme-on-surface), 0.03)')
|
|
57
|
+
expect(rootTag(html)).toContain('--tf-group-shadow:none')
|
|
58
|
+
expect(rootTag(html)).toContain('--tf-button-radius:6px')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders an inline skin object under tiptapify-skin-inline with merged tokens', async () => {
|
|
62
|
+
const html = await renderShell({ colors: { toolbarBg: 'rgb(1, 2, 3)' }, metrics: { buttonRadius: '9px' } })
|
|
63
|
+
|
|
64
|
+
expect(rootTag(html)).toContain('tiptapify-skin-inline')
|
|
65
|
+
expect(rootTag(html)).toContain('--tf-toolbar-bg:rgb(1, 2, 3)')
|
|
66
|
+
expect(rootTag(html)).toContain('--tf-button-radius:9px')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('a named inline object renders under tiptapify-skin-{name}, not the inline name', async () => {
|
|
70
|
+
const html = await renderShell({
|
|
71
|
+
name: 'mint',
|
|
72
|
+
extends: 'pastel',
|
|
73
|
+
colors: { toolbarBg: 'rgba(0, 128, 0, 0.05)' },
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
expect(rootTag(html)).toContain('tiptapify-skin-mint')
|
|
77
|
+
expect(rootTag(html)).not.toContain('tiptapify-skin-inline')
|
|
78
|
+
expect(rootTag(html)).toContain('--tf-toolbar-bg:rgba(0, 128, 0, 0.05)')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('falls back to the default skin for an unknown name', async () => {
|
|
82
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
83
|
+
const html = await renderShell('nope')
|
|
84
|
+
|
|
85
|
+
expect(rootTag(html)).toContain('tiptapify-skin-default')
|
|
86
|
+
expect(rootTag(html)).not.toContain('--tf-')
|
|
87
|
+
expect(warn).toHaveBeenCalledWith(expect.stringContaining('nope'))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('re-resolves the skin when the prop changes, without re-rendering the editor', async () => {
|
|
91
|
+
const skin = ref<unknown>('default')
|
|
92
|
+
const { app, host } = await mountEditor(skin)
|
|
93
|
+
|
|
94
|
+
const root = host.querySelector('[id^=tiptapify-editor-]') as HTMLElement
|
|
95
|
+
expect(root.className).toContain('tiptapify-skin-default')
|
|
96
|
+
|
|
97
|
+
const proseMirror = host.querySelector('.ProseMirror')
|
|
98
|
+
expect(proseMirror).toBeTruthy()
|
|
99
|
+
|
|
100
|
+
skin.value = { colors: { toolbarBg: 'rgb(9, 9, 9)' } }
|
|
101
|
+
await nextTick()
|
|
102
|
+
|
|
103
|
+
expect(root.className).toContain('tiptapify-skin-inline')
|
|
104
|
+
expect(root.style.getPropertyValue('--tf-toolbar-bg')).toBe('rgb(9, 9, 9)')
|
|
105
|
+
// The editor DOM is the same node: a skin change never recreates the editor
|
|
106
|
+
expect(host.querySelector('.ProseMirror')).toBe(proseMirror)
|
|
107
|
+
|
|
108
|
+
app.unmount()
|
|
109
|
+
host.remove()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('keeps the pastel toolbar quiet through the resolved skin name', async () => {
|
|
113
|
+
const skin = ref<unknown>('pastel')
|
|
114
|
+
const { app, host } = await mountEditor(skin)
|
|
115
|
+
|
|
116
|
+
const toolbar = host.querySelector('.v-toolbar')
|
|
117
|
+
expect(toolbar?.classList.contains('v-toolbar--flat')).toBe(true)
|
|
118
|
+
|
|
119
|
+
app.unmount()
|
|
120
|
+
host.remove()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('the root carries all six shape classes: classic values for the default skin', async () => {
|
|
124
|
+
const tag = rootTag(await renderShell('default'))
|
|
125
|
+
|
|
126
|
+
expect(tag).toContain('tiptapify-shape-toolbar-raised')
|
|
127
|
+
expect(tag).toContain('tiptapify-shape-groups-box')
|
|
128
|
+
expect(tag).toContain('tiptapify-shape-buttons-vuetify')
|
|
129
|
+
expect(tag).toContain('tiptapify-shape-bubble-card')
|
|
130
|
+
expect(tag).toContain('tiptapify-shape-dialog-classic')
|
|
131
|
+
expect(tag).toContain('tiptapify-shape-menu-vuetify')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('the pastel root carries the quiet shape classes and keeps the skin name class', async () => {
|
|
135
|
+
const tag = rootTag(await renderShell('pastel'))
|
|
136
|
+
|
|
137
|
+
expect(tag).toContain('tiptapify-skin-pastel')
|
|
138
|
+
expect(tag).toContain('tiptapify-shape-toolbar-flat')
|
|
139
|
+
expect(tag).toContain('tiptapify-shape-groups-flat')
|
|
140
|
+
expect(tag).toContain('tiptapify-shape-buttons-quiet')
|
|
141
|
+
expect(tag).toContain('tiptapify-shape-bubble-flat')
|
|
142
|
+
expect(tag).toContain('tiptapify-shape-dialog-soft')
|
|
143
|
+
expect(tag).toContain('tiptapify-shape-menu-soft')
|
|
144
|
+
expect(tag).not.toContain('tiptapify-shape-dialog-classic')
|
|
145
|
+
expect(tag).not.toContain('tiptapify-shape-buttons-vuetify')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('an inline shape object lands on the root classes; the flags it omits stay classic', async () => {
|
|
149
|
+
const tag = rootTag(await renderShell({ buttons: 'quiet', dialog: 'soft' }))
|
|
150
|
+
|
|
151
|
+
expect(tag).toContain('tiptapify-skin-inline')
|
|
152
|
+
expect(tag).toContain('tiptapify-shape-buttons-quiet')
|
|
153
|
+
expect(tag).toContain('tiptapify-shape-dialog-soft')
|
|
154
|
+
expect(tag).toContain('tiptapify-shape-toolbar-raised')
|
|
155
|
+
expect(tag).toContain('tiptapify-shape-groups-box')
|
|
156
|
+
expect(tag).toContain('tiptapify-shape-bubble-card')
|
|
157
|
+
expect(tag).toContain('tiptapify-shape-menu-vuetify')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('a custom skin extending pastel keeps the pastel shape classes under the inline name', async () => {
|
|
161
|
+
const tag = rootTag(await renderShell({ extends: 'pastel' }))
|
|
162
|
+
|
|
163
|
+
expect(tag).toContain('tiptapify-skin-inline')
|
|
164
|
+
expect(tag).toContain('tiptapify-shape-toolbar-flat')
|
|
165
|
+
expect(tag).toContain('tiptapify-shape-groups-flat')
|
|
166
|
+
expect(tag).toContain('tiptapify-shape-buttons-quiet')
|
|
167
|
+
expect(tag).toContain('tiptapify-shape-bubble-flat')
|
|
168
|
+
expect(tag).toContain('tiptapify-shape-dialog-soft')
|
|
169
|
+
expect(tag).toContain('tiptapify-shape-menu-soft')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('the toolbar elevation follows the toolbar shape flag, not the skin name', async () => {
|
|
173
|
+
const skin = ref<unknown>({ toolbar: 'flat' })
|
|
174
|
+
const { app, host } = await mountEditor(skin)
|
|
175
|
+
|
|
176
|
+
expect(host.querySelector('.v-toolbar')?.classList.contains('v-toolbar--flat')).toBe(true)
|
|
177
|
+
|
|
178
|
+
skin.value = { toolbar: 'raised' }
|
|
179
|
+
await nextTick()
|
|
180
|
+
|
|
181
|
+
expect(host.querySelector('.v-toolbar')?.classList.contains('v-toolbar--flat')).toBe(false)
|
|
182
|
+
|
|
183
|
+
app.unmount()
|
|
184
|
+
host.remove()
|
|
185
|
+
})
|
|
186
|
+
})
|