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
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.
|
|
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="
|
|
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,
|
|
92
|
-
|
|
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
|
|
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>
|