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.
Files changed (87) hide show
  1. package/README.md +1 -0
  2. package/dist/tiptapify.css +1 -1
  3. package/dist/tiptapify.mjs +54477 -48112
  4. package/dist/tiptapify.umd.js +68 -68
  5. package/index.d.ts +96 -0
  6. package/package.json +2 -1
  7. package/src/components/Footer.vue +12 -2
  8. package/src/components/ImageBubbleMenu.vue +139 -0
  9. package/src/components/MenuBubble.vue +60 -7
  10. package/src/components/SkinScope.test.ts +74 -0
  11. package/src/components/SkinScope.vue +18 -0
  12. package/src/components/Tiptapify.skin.test.ts +186 -0
  13. package/src/components/Tiptapify.vue +285 -74
  14. package/src/components/Toolbar/Index.test.ts +96 -0
  15. package/src/components/Toolbar/Index.vue +199 -14
  16. package/src/components/Toolbar/Items.test.ts +82 -2
  17. package/src/components/Toolbar/Items.vue +104 -19
  18. package/src/components/Toolbar/alignment.ts +5 -0
  19. package/src/components/UI/TiptapifyDialog.test.ts +132 -0
  20. package/src/components/UI/TiptapifyDialog.vue +128 -12
  21. package/src/components/editorExtensions.test.ts +10 -0
  22. package/src/components/editorExtensions.ts +34 -5
  23. package/src/components/index.ts +3 -2
  24. package/src/composables/useTiptapifyConfig.test.ts +25 -2
  25. package/src/composables/useTiptapifyConfig.ts +21 -2
  26. package/src/constants/defaults.ts +2 -1
  27. package/src/extensions/components/actions/redo/Button.vue +1 -1
  28. package/src/extensions/components/actions/undo/Button.vue +1 -1
  29. package/src/extensions/components/alignment/menu/Button.vue +72 -0
  30. package/src/extensions/components/list/bullet/Button.vue +35 -16
  31. package/src/extensions/components/list/numbered/Button.vue +19 -0
  32. package/src/extensions/components/list/styles/ListStyleButton.vue +156 -0
  33. package/src/extensions/components/list/styles/index.ts +76 -0
  34. package/src/extensions/components/list/styles/listStyle.test.ts +44 -0
  35. package/src/extensions/components/media/image/ImageDialog.vue +1 -1
  36. package/src/extensions/components/media/image/caption.test.ts +122 -0
  37. package/src/extensions/components/media/image/caption.ts +268 -0
  38. package/src/extensions/components/media/image/slider.test.ts +106 -0
  39. package/src/extensions/components/media/image/slider.ts +87 -0
  40. package/src/extensions/components/style/heading/Button.vue +45 -30
  41. package/src/i18n/locales/ar.json +22 -0
  42. package/src/i18n/locales/bg.json +22 -0
  43. package/src/i18n/locales/ch.json +22 -0
  44. package/src/i18n/locales/cz.json +22 -0
  45. package/src/i18n/locales/de.json +22 -0
  46. package/src/i18n/locales/el.json +22 -0
  47. package/src/i18n/locales/en.json +23 -1
  48. package/src/i18n/locales/es.json +22 -0
  49. package/src/i18n/locales/et.json +22 -0
  50. package/src/i18n/locales/fi.json +22 -0
  51. package/src/i18n/locales/fr.json +22 -0
  52. package/src/i18n/locales/he.json +22 -0
  53. package/src/i18n/locales/hi.json +22 -0
  54. package/src/i18n/locales/hr.json +22 -0
  55. package/src/i18n/locales/hu.json +22 -0
  56. package/src/i18n/locales/id.json +22 -0
  57. package/src/i18n/locales/it.json +22 -0
  58. package/src/i18n/locales/ja.json +22 -0
  59. package/src/i18n/locales/ko.json +22 -0
  60. package/src/i18n/locales/la.json +22 -0
  61. package/src/i18n/locales/lt.json +22 -0
  62. package/src/i18n/locales/nl.json +22 -0
  63. package/src/i18n/locales/no.json +22 -0
  64. package/src/i18n/locales/pl.json +22 -0
  65. package/src/i18n/locales/pt-BR.json +22 -0
  66. package/src/i18n/locales/pt.json +22 -0
  67. package/src/i18n/locales/ro.json +22 -0
  68. package/src/i18n/locales/ru.json +23 -1
  69. package/src/i18n/locales/se.json +22 -0
  70. package/src/i18n/locales/sk.json +22 -0
  71. package/src/i18n/locales/sl.json +22 -0
  72. package/src/i18n/locales/sr.json +22 -0
  73. package/src/i18n/locales/th.json +22 -0
  74. package/src/i18n/locales/tr.json +22 -0
  75. package/src/i18n/locales/uk.json +23 -1
  76. package/src/i18n/locales/vi.json +22 -0
  77. package/src/index.test.ts +88 -0
  78. package/src/index.ts +15 -0
  79. package/src/skins/__fixtures__/skin-baseline.json +805 -0
  80. package/src/skins/builtins.ts +81 -0
  81. package/src/skins/menu.css +87 -0
  82. package/src/skins/menu.ts +10 -0
  83. package/src/skins/registry.test.ts +180 -0
  84. package/src/skins/registry.ts +132 -0
  85. package/src/skins/tokens.reachability.test.ts +43 -0
  86. package/src/skins/tokens.ts +92 -0
  87. package/src/types/editor.ts +99 -0
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
  }
@@ -192,6 +287,7 @@ export {
192
287
  TIPTAPIFY_CONFIG_KEY,
193
288
  useTiptapifyConfig,
194
289
  TiptapEditor,
290
+ TiptapEditor as TipTapEditor,
195
291
  nodeViewProps,
196
292
  NodeViewWrapper,
197
293
  VueNodeViewRenderer,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tiptapify",
3
3
  "types": "./index.d.ts",
4
- "version": "0.3.2",
4
+ "version": "0.3.4",
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"
@@ -57,16 +57,16 @@ function showFooter() {
57
57
  <VRow>
58
58
  <VCol class="d-flex align-center" :class="`justify-${alignment}`">
59
59
  <template v-if="limit > 0">
60
- <VTooltip :open-delay="1500">
60
+ <VTooltip :open-delay="200" location="top">
61
61
  <template #default>
62
62
  <span v-html="limitText" />
63
63
  </template>
64
64
  <template #activator="{ props }">
65
+ <span class="tiptapify-footer--limit" v-bind="props" :aria-label="limitText.replace('<br>', ': ')">
65
66
  <svg
66
67
  height="25"
67
68
  width="25"
68
69
  viewBox="0 0 20 20"
69
- v-bind="props"
70
70
  :class="{
71
71
  'character-count': true,
72
72
  'character-count--alert': percentage > 75,
@@ -86,6 +86,8 @@ function showFooter() {
86
86
  />
87
87
  <circle r="6" cx="10" cy="10" fill="white" />
88
88
  </svg>
89
+ <span class="tiptapify-footer--status-item">{{ Math.min(percentage, 999) }}%</span>
90
+ </span>
89
91
  </template>
90
92
  </VTooltip>
91
93
 
@@ -113,6 +115,14 @@ function showFooter() {
113
115
  color: #999;
114
116
  }
115
117
 
118
+ /* The limit ring with its percentage; the tooltip spells out the limit */
119
+ .tiptapify-footer--limit {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 4px;
123
+ cursor: help;
124
+ }
125
+
116
126
  .tiptapify-footer--divider:not(:last-child) {
117
127
  margin: 0 8px;
118
128
  }
@@ -0,0 +1,139 @@
1
+ <script setup lang="ts">
2
+ import * as mdi from '@mdi/js'
3
+ import { Editor } from '@tiptap/vue-3'
4
+ import { BubbleMenu } from '@tiptap/vue-3/menus'
5
+ import { NodeSelection } from '@tiptap/pm/state'
6
+ import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
7
+ import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
8
+ import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
9
+ import { inject, Ref, ref } from 'vue'
10
+ import { ComposerTranslation } from 'vue-i18n'
11
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
12
+
13
+ // The toolbar on a selected image: float toggles (or, for a slide, its place in the slider), download, edit, removal
14
+ const editor = inject('tiptapifyEditor') as Ref<Editor>
15
+
16
+ // The image menu follows the buttons shape flag: a quiet carrier in the quiet shape
17
+ const { shape } = useTiptapifyConfig()
18
+
19
+ // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
20
+ // box is there, as the menu reads its options only when it is created
21
+ const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
22
+
23
+ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
24
+
25
+ // Without a float the image stands centered on its own line; a second click on a float removes it
26
+ const alignments: Array<{ value: ImageAlign, icon: string, label: string }> = [
27
+ { value: 'left', icon: mdi.mdiFormatFloatLeft, label: 'media.image_toolbar.align_left' },
28
+ { value: 'right', icon: mdi.mdiFormatFloatRight, label: 'media.image_toolbar.align_right' },
29
+ ]
30
+
31
+ function isImageSelected({ state }: { state: Editor['state'] }): boolean {
32
+ return state.selection instanceof NodeSelection && state.selection.node.type.name === 'image'
33
+ }
34
+
35
+ function toggleAlign(align: ImageAlign) {
36
+ const current = editor.value.getAttributes('image').align ?? null
37
+ editor.value.chain().focus().updateAttributes('image', { align: current === align ? null : align }).run()
38
+ }
39
+
40
+ // A slide of a slider (extensions/components/media/image/slider.ts) moves among the slides instead of floating
41
+ function isSlide(): boolean {
42
+ if (!isImageSelected(editor.value)) {
43
+ return false
44
+ }
45
+
46
+ // The selection may sit in text while the menu hides, where the node at it is a text node
47
+ const dom = editor.value.view.nodeDOM(editor.value.state.selection.from)
48
+
49
+ return dom instanceof HTMLElement && dom.classList.contains('tiptapify-slide')
50
+ }
51
+
52
+ function download() {
53
+ const { src } = editor.value.getAttributes('image')
54
+ if (src) {
55
+ window.open(src, '_blank', 'noopener')
56
+ }
57
+ }
58
+
59
+ </script>
60
+
61
+ <template>
62
+ <BubbleMenu
63
+ v-if="editor && scrollTarget"
64
+ :editor="editor"
65
+ plugin-key="tiptapifyImageMenu"
66
+ :options="{ placement: 'top', offset: -52, flip: false, scrollTarget }"
67
+ :should-show="isImageSelected"
68
+ >
69
+ <div class="tiptapify-image-menu" :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }">
70
+ <template v-if="isSlide()">
71
+ <VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(-1)" @click="editor.chain().focus().moveImage(-1).run()">
72
+ <Tooltip :label="t('media.image_toolbar.move_back')" />
73
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiArrowLeft}`" />
74
+ </VBtn>
75
+ <VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(1)" @click="editor.chain().focus().moveImage(1).run()">
76
+ <Tooltip :label="t('media.image_toolbar.move_forward')" />
77
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiArrowRight}`" />
78
+ </VBtn>
79
+ </template>
80
+ <template v-else>
81
+ <VBtn
82
+ v-for="alignment in alignments"
83
+ :key="alignment.label"
84
+ size="32"
85
+ variant="text"
86
+ color=""
87
+ :class="{ 'tiptapify-btn--active': (editor.getAttributes('image').align ?? null) === alignment.value }"
88
+ @click="toggleAlign(alignment.value)"
89
+ >
90
+ <Tooltip :label="t(alignment.label)" />
91
+ <BtnIcon :icon="`mdiSvg:${alignment.icon}`" />
92
+ </VBtn>
93
+ </template>
94
+
95
+ <div class="tiptapify-image-menu__divider" />
96
+
97
+ <VBtn size="32" variant="text" color="" @click="download">
98
+ <Tooltip :label="t('media.image_toolbar.download')" />
99
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiTrayArrowDown}`" />
100
+ </VBtn>
101
+ <VBtn size="32" variant="text" color="" @click="editor.commands.showTiptapifyImage()">
102
+ <Tooltip :label="t('media.image_toolbar.edit')" />
103
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiImageEditOutline}`" />
104
+ </VBtn>
105
+
106
+ <div class="tiptapify-image-menu__divider" />
107
+
108
+ <VBtn size="32" variant="text" color="" @click="editor.chain().focus().deleteSelection().run()">
109
+ <Tooltip :label="t('media.image_toolbar.delete')" />
110
+ <BtnIcon :icon="`mdiSvg:${mdi.mdiTrashCanOutline}`" />
111
+ </VBtn>
112
+ </div>
113
+ </BubbleMenu>
114
+ </template>
115
+
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
+ */
122
+ .tiptapify-image-menu {
123
+ display: flex;
124
+ align-items: center;
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));
131
+ }
132
+
133
+ .tiptapify-image-menu__divider {
134
+ width: 1px;
135
+ height: 20px;
136
+ margin: 0 4px;
137
+ background: rgba(var(--v-border-color), var(--v-border-opacity));
138
+ }
139
+ </style>
@@ -2,7 +2,9 @@
2
2
  import { Editor } from '@tiptap/vue-3'
3
3
  import { computed, inject, Ref, ref } from 'vue'
4
4
  import { BubbleMenu } from '@tiptap/vue-3/menus'
5
+ import { NodeSelection } from '@tiptap/pm/state'
5
6
  import * as mdi from '@mdi/js'
7
+ import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
6
8
 
7
9
  defineProps({
8
10
  variant: { type: String, default () { return 'flat' } },
@@ -11,6 +13,14 @@ defineProps({
11
13
 
12
14
  const editor = inject('tiptapifyEditor') as Ref<Editor>
13
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
+
20
+ // The menu follows its text or image when the editor scrolls inside its own box; it is created once the
21
+ // box is there, as the menu reads its options only when it is created
22
+ const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
23
+
14
24
  const items = ref([
15
25
  {
16
26
  name: 'bold',
@@ -85,11 +95,16 @@ const items = ref([
85
95
 
86
96
  <template>
87
97
  <BubbleMenu
88
- v-if="editor"
98
+ v-if="editor && scrollTarget"
89
99
  :editor="editor"
90
- :options="{ placement: 'bottom' }"
91
- :should-show="({ editor, view, state, from, to }) => {
92
- if (editor.isActive('image') || editor.isActive('code') || editor.isActive('codeBlock')) {
100
+ :options="{ placement: 'bottom', scrollTarget }"
101
+ :should-show="({ editor, state, from, to }) => {
102
+ // A selected block (image, paywall, widget) is not text to format
103
+ if (state.selection instanceof NodeSelection) {
104
+ return false
105
+ }
106
+
107
+ if (editor.isActive('code') || editor.isActive('codeBlock')) {
93
108
  return false
94
109
  }
95
110
 
@@ -99,7 +114,27 @@ const items = ref([
99
114
  return (from !== to) && !isAllSelected
100
115
  }"
101
116
  >
102
- <div class="bubble-menu">
117
+ <div
118
+ v-if="shape.bubble === 'flat'"
119
+ class="tiptapify-bubble-menu"
120
+ :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }"
121
+ >
122
+ <VBtn
123
+ v-for="(item, key) in items"
124
+ :key="key"
125
+ size="32"
126
+ variant="text"
127
+ color=""
128
+ :disabled="item.props?.disabled"
129
+ :class="{ 'tiptapify-btn--active': item.props?.color === 'primary' }"
130
+ @click="item.click"
131
+ >
132
+ <VIcon :icon="item.icon" size="20" />
133
+ </VBtn>
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">
103
138
  <VCard>
104
139
  <VCardText class="pa-0">
105
140
  <VToolbar :theme="theme" density="compact" height="auto" class="p-0">
@@ -118,8 +153,26 @@ const items = ref([
118
153
  </template>
119
154
 
120
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
+ */
121
163
  .bubble-menu {
122
- border-radius: 6px;
123
- box-shadow: 4px 4px 20px var(--dark-gray);
164
+ border-radius: var(--tf-bubble-radius, 6px);
165
+ box-shadow: var(--tf-bubble-shadow, 4px 4px 20px var(--dark-gray));
166
+ }
167
+
168
+ .tiptapify-bubble-menu {
169
+ display: flex;
170
+ align-items: center;
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));
124
177
  }
125
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>