tiptapify 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/content.css +28 -0
- package/dist/tiptapify.css +1 -1
- package/dist/tiptapify.mjs +110820 -65669
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +34 -0
- package/package.json +3 -2
- package/src/components/ImageBubbleMenu.vue +5 -33
- package/src/components/MenuBubble.test.ts +120 -0
- package/src/components/MenuBubble.vue +124 -134
- package/src/components/MenuFloating.vue +48 -45
- package/src/components/Tiptapify.upload.test.ts +10 -0
- package/src/components/Tiptapify.vue +85 -45
- package/src/components/Toolbar/Index.vue +10 -0
- package/src/components/UI/BubbleRow.vue +41 -0
- package/src/components/UI/TiptapifyDialog.test.ts +37 -3
- package/src/components/UI/TiptapifyDialog.vue +15 -4
- package/src/components/editorExtensions.test.ts +9 -0
- package/src/components/editorExtensions.ts +21 -1
- package/src/composables/fakeUploadEndpoint.ts +65 -0
- package/src/composables/useTiptapifyConfig.ts +19 -0
- package/src/composables/useTiptapifyLinkPreview.test.ts +51 -0
- package/src/composables/useTiptapifyLinkPreview.ts +60 -0
- package/src/composables/useTiptapifyUpload.test.ts +96 -0
- package/src/composables/useTiptapifyUpload.ts +126 -0
- package/src/extensions/components/ai/Dialog.test.ts +335 -0
- package/src/extensions/components/ai/Dialog.vue +607 -310
- package/src/extensions/components/ai/backend.test.ts +1 -0
- package/src/extensions/components/ai/backend.ts +2 -1
- package/src/extensions/components/ai/context.test.ts +149 -0
- package/src/extensions/components/ai/context.ts +145 -0
- package/src/extensions/components/ai/diff.test.ts +65 -0
- package/src/extensions/components/ai/diff.ts +120 -0
- package/src/extensions/components/ai/errors.test.ts +45 -0
- package/src/extensions/components/ai/errors.ts +67 -0
- package/src/extensions/components/ai/result.test.ts +144 -0
- package/src/extensions/components/ai/result.ts +101 -0
- package/src/extensions/components/ai/thinking.test.ts +45 -0
- package/src/extensions/components/ai/thinking.ts +58 -0
- package/src/extensions/components/formatExtra/sub/Button.vue +1 -1
- package/src/extensions/components/formatExtra/sup/Button.vue +1 -1
- package/src/extensions/components/media/iframe/Button.vue +6 -7
- package/src/extensions/components/media/iframe/IframeDialog.test.ts +177 -0
- package/src/extensions/components/media/iframe/IframeDialog.vue +477 -105
- package/src/extensions/components/media/iframe/embed.test.ts +123 -0
- package/src/extensions/components/media/iframe/embed.ts +236 -0
- package/src/extensions/components/media/iframe/index.test.ts +123 -0
- package/src/extensions/components/media/iframe/index.ts +92 -10
- package/src/extensions/components/media/image/ImageDialog.vue +430 -132
- package/src/extensions/components/media/image/caption.test.ts +66 -1
- package/src/extensions/components/media/image/caption.ts +27 -1
- package/src/extensions/components/media/image/imageDialog.test.ts +200 -0
- package/src/extensions/components/media/image/imageDialog.ts +114 -0
- package/src/extensions/components/media/image/index.ts +2 -1
- package/src/extensions/components/media/link/Button.test.ts +96 -0
- package/src/extensions/components/media/link/Button.vue +6 -2
- package/src/extensions/components/media/link/LinkDialog.display.test.ts +363 -0
- package/src/extensions/components/media/link/LinkDialog.vue +687 -61
- package/src/extensions/components/media/link/index.ts +111 -6
- package/src/extensions/components/media/link/link.marks.test.ts +89 -0
- package/src/extensions/components/media/link/linkDialog.test.ts +296 -0
- package/src/extensions/components/media/link/linkDialog.ts +154 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.test.ts +133 -0
- package/src/extensions/components/media/linkCard/PasteCardMenu.vue +168 -0
- package/src/extensions/components/media/linkCard/index.test.ts +146 -0
- package/src/extensions/components/media/linkCard/index.ts +177 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.test.ts +87 -0
- package/src/extensions/components/media/linkCard/linkCardDialog.ts +62 -0
- package/src/extensions/components/media/linkCard/linkPreview.test.ts +102 -0
- package/src/extensions/components/media/linkCard/linkPreview.ts +84 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.test.ts +59 -0
- package/src/extensions/components/media/linkCard/makeLinkCard.ts +58 -0
- package/src/extensions/components/media/linkCard/pasteCard.test.ts +190 -0
- package/src/extensions/components/media/linkCard/pasteCard.ts +153 -0
- package/src/extensions/components/media/video/Button.vue +2 -1
- package/src/extensions/components/media/video/VideoDialog.test.ts +233 -0
- package/src/extensions/components/media/video/VideoDialog.vue +748 -59
- package/src/extensions/components/media/video/index.test.ts +154 -0
- package/src/extensions/components/media/video/index.ts +70 -5
- package/src/extensions/components/media/video/providers.test.ts +148 -0
- package/src/extensions/components/media/video/providers.ts +241 -0
- package/src/extensions/components/media/video/videoFile.test.ts +98 -0
- package/src/extensions/components/media/video/videoFile.ts +142 -0
- package/src/extensions/components/misc/fullscreen/Button.test.ts +55 -0
- package/src/extensions/components/misc/fullscreen/Button.vue +2 -2
- package/src/extensions/components/style/StyleColor.test.ts +255 -0
- package/src/extensions/components/style/StyleColor.vue +358 -208
- package/src/extensions/components/style/color/Button.vue +1 -3
- package/src/extensions/components/style/color/colors.test.ts +191 -0
- package/src/extensions/components/style/color/colors.ts +168 -0
- package/src/extensions/components/style/highlight/Button.vue +1 -3
- package/src/i18n/locales/ar.json +226 -23
- package/src/i18n/locales/bg.json +225 -22
- package/src/i18n/locales/ch.json +226 -23
- package/src/i18n/locales/cz.json +226 -23
- package/src/i18n/locales/de.json +226 -23
- package/src/i18n/locales/el.json +225 -22
- package/src/i18n/locales/en.json +225 -22
- package/src/i18n/locales/es.json +226 -23
- package/src/i18n/locales/et.json +225 -22
- package/src/i18n/locales/fi.json +226 -23
- package/src/i18n/locales/fr.json +226 -23
- package/src/i18n/locales/he.json +225 -22
- package/src/i18n/locales/hi.json +225 -22
- package/src/i18n/locales/hr.json +225 -22
- package/src/i18n/locales/hu.json +226 -23
- package/src/i18n/locales/id.json +225 -22
- package/src/i18n/locales/it.json +226 -23
- package/src/i18n/locales/ja.json +226 -23
- package/src/i18n/locales/ko.json +226 -23
- package/src/i18n/locales/la.json +226 -23
- package/src/i18n/locales/lt.json +226 -23
- package/src/i18n/locales/nl.json +226 -23
- package/src/i18n/locales/no.json +227 -24
- package/src/i18n/locales/pl.json +226 -23
- package/src/i18n/locales/pt-BR.json +225 -22
- package/src/i18n/locales/pt.json +226 -23
- package/src/i18n/locales/ro.json +225 -22
- package/src/i18n/locales/ru.json +227 -24
- package/src/i18n/locales/se.json +226 -23
- package/src/i18n/locales/sk.json +225 -22
- package/src/i18n/locales/sl.json +225 -22
- package/src/i18n/locales/sr.json +225 -22
- package/src/i18n/locales/th.json +226 -23
- package/src/i18n/locales/tr.json +226 -23
- package/src/i18n/locales/uk.json +227 -24
- package/src/i18n/locales/vi.json +226 -23
- package/src/index.ts +8 -0
- package/src/skins/registry.test.ts +1 -1
- package/src/skins/tokens.reachability.test.ts +1 -0
- package/src/skins/tokens.ts +3 -3
- package/src/types/editor.ts +40 -1
- package/src/utils/contentStyles.test.ts +116 -0
- package/src/utils/contentStyles.ts +175 -0
- package/src/utils/mediaSize.test.ts +22 -0
- package/src/utils/mediaSize.ts +27 -0
- package/src/utils/validateHref.test.ts +72 -2
- package/src/utils/validateHref.ts +57 -13
package/index.d.ts
CHANGED
|
@@ -157,11 +157,41 @@ export type TiptapifySkinConfig = TiptapifyShape & {
|
|
|
157
157
|
// The `skin` prop: a registered name (string) or an inline object
|
|
158
158
|
export type TiptapifySkinInput = string | TiptapifySkinConfig
|
|
159
159
|
|
|
160
|
+
export type TiptapifyUploadKind = 'image' | 'video'
|
|
161
|
+
|
|
162
|
+
/** `POST {endpoint}` takes a file (multipart `file` and `kind`) and answers `{ url }`, an error `{ error }` */
|
|
163
|
+
export type TiptapifyUploadOptions = {
|
|
164
|
+
endpoint: string,
|
|
165
|
+
headers?: Record<string, string>,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type TiptapifyLinkPreviewOptions = {
|
|
169
|
+
endpoint: string,
|
|
170
|
+
headers?: Record<string, string>,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type TiptapifyLinkPreviewData = {
|
|
174
|
+
url: string,
|
|
175
|
+
title?: string,
|
|
176
|
+
description?: string,
|
|
177
|
+
image?: string,
|
|
178
|
+
siteName?: string,
|
|
179
|
+
}
|
|
180
|
+
|
|
160
181
|
export declare function registerSkin(name: string, skin: TiptapifySkinConfig): void
|
|
161
182
|
|
|
162
183
|
// Identity for typed const definitions; the object never enters the registry
|
|
163
184
|
export declare function defineSkin(skin: TiptapifySkinConfig): TiptapifySkinConfig
|
|
164
185
|
|
|
186
|
+
/** How the saved HTML carries the look of its content: `inline` styles, or `classes` for tiptapify/content.css */
|
|
187
|
+
export type TiptapifyContentStyles = 'inline' | 'classes'
|
|
188
|
+
|
|
189
|
+
/** The content HTML with the content styles written into its `style` attributes (unchanged without a DOM) */
|
|
190
|
+
export declare function inlineContentStyles(html: string): string
|
|
191
|
+
|
|
192
|
+
/** The content styles as a stylesheet for the content inside `scope` (`.tiptapify-content`), weightless by default */
|
|
193
|
+
export declare function contentCss(scope?: string, options?: { weightless?: boolean }): string
|
|
194
|
+
|
|
165
195
|
export interface TiptapifyOptions {
|
|
166
196
|
i18n?: string
|
|
167
197
|
}
|
|
@@ -183,6 +213,10 @@ export interface TiptapifyAiChatMessage {
|
|
|
183
213
|
export interface TiptapifyAiEditorContext {
|
|
184
214
|
prompt: string
|
|
185
215
|
selectedText: string
|
|
216
|
+
/** The text of the selection's paragraph before it (empty without a selection) */
|
|
217
|
+
textBefore: string
|
|
218
|
+
/** The text of the selection's paragraph after it (empty without a selection) */
|
|
219
|
+
textAfter: string
|
|
186
220
|
text: string
|
|
187
221
|
html: string
|
|
188
222
|
json: object
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tiptapify",
|
|
3
3
|
"types": "./index.d.ts",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "Tiptap3 editor with Vuetify3 menu implementation",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
},
|
|
17
17
|
"./src/*": "./src/*",
|
|
18
18
|
"./dist/*": "./dist/*",
|
|
19
|
-
"./style.css": "./dist/tiptapify.css"
|
|
19
|
+
"./style.css": "./dist/tiptapify.css",
|
|
20
|
+
"./content.css": "./dist/content.css"
|
|
20
21
|
},
|
|
21
22
|
"main": "./dist/tiptapify.umd.cjs",
|
|
22
23
|
"module": "./dist/tiptapify.mjs",
|
|
@@ -4,18 +4,15 @@ import { Editor } from '@tiptap/vue-3'
|
|
|
4
4
|
import { BubbleMenu } from '@tiptap/vue-3/menus'
|
|
5
5
|
import { NodeSelection } from '@tiptap/pm/state'
|
|
6
6
|
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
7
|
+
import BubbleRow from '@tiptapify/components/UI/BubbleRow.vue'
|
|
7
8
|
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
8
9
|
import { ImageAlign } from '@tiptapify/extensions/components/media/image/caption'
|
|
9
10
|
import { inject, Ref, ref } from 'vue'
|
|
10
11
|
import { ComposerTranslation } from 'vue-i18n'
|
|
11
|
-
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
12
12
|
|
|
13
13
|
// The toolbar on a selected image: float toggles (or, for a slide, its place in the slider), download, edit, removal
|
|
14
14
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
15
15
|
|
|
16
|
-
// The image menu follows the buttons shape flag: a quiet carrier in the quiet shape
|
|
17
|
-
const { shape } = useTiptapifyConfig()
|
|
18
|
-
|
|
19
16
|
// The menu follows its text or image when the editor scrolls inside its own box; it is created once the
|
|
20
17
|
// box is there, as the menu reads its options only when it is created
|
|
21
18
|
const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
|
|
@@ -66,7 +63,7 @@ function download() {
|
|
|
66
63
|
:options="{ placement: 'top', offset: -52, flip: false, scrollTarget }"
|
|
67
64
|
:should-show="isImageSelected"
|
|
68
65
|
>
|
|
69
|
-
<
|
|
66
|
+
<BubbleRow class="tiptapify-image-menu">
|
|
70
67
|
<template v-if="isSlide()">
|
|
71
68
|
<VBtn size="32" variant="text" color="" :disabled="!editor.can().moveImage(-1)" @click="editor.chain().focus().moveImage(-1).run()">
|
|
72
69
|
<Tooltip :label="t('media.image_toolbar.move_back')" />
|
|
@@ -92,7 +89,7 @@ function download() {
|
|
|
92
89
|
</VBtn>
|
|
93
90
|
</template>
|
|
94
91
|
|
|
95
|
-
<div class="tiptapify-
|
|
92
|
+
<div class="tiptapify-bubble-divider" />
|
|
96
93
|
|
|
97
94
|
<VBtn size="32" variant="text" color="" @click="download">
|
|
98
95
|
<Tooltip :label="t('media.image_toolbar.download')" />
|
|
@@ -103,37 +100,12 @@ function download() {
|
|
|
103
100
|
<BtnIcon :icon="`mdiSvg:${mdi.mdiImageEditOutline}`" />
|
|
104
101
|
</VBtn>
|
|
105
102
|
|
|
106
|
-
<div class="tiptapify-
|
|
103
|
+
<div class="tiptapify-bubble-divider" />
|
|
107
104
|
|
|
108
105
|
<VBtn size="32" variant="text" color="" @click="editor.chain().focus().deleteSelection().run()">
|
|
109
106
|
<Tooltip :label="t('media.image_toolbar.delete')" />
|
|
110
107
|
<BtnIcon :icon="`mdiSvg:${mdi.mdiTrashCanOutline}`" />
|
|
111
108
|
</VBtn>
|
|
112
|
-
</
|
|
109
|
+
</BubbleRow>
|
|
113
110
|
</BubbleMenu>
|
|
114
111
|
</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>
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { App, createApp, h, nextTick, provide, ref, shallowRef } from 'vue'
|
|
3
|
+
import { createVuetify } from 'vuetify'
|
|
4
|
+
import { Editor } from '@tiptap/vue-3'
|
|
5
|
+
import { Bold } from '@tiptap/extension-bold'
|
|
6
|
+
import { Code } from '@tiptap/extension-code'
|
|
7
|
+
import { Document } from '@tiptap/extension-document'
|
|
8
|
+
import { Highlight } from '@tiptap/extension-highlight'
|
|
9
|
+
import { Italic } from '@tiptap/extension-italic'
|
|
10
|
+
import { Paragraph } from '@tiptap/extension-paragraph'
|
|
11
|
+
import { Strike } from '@tiptap/extension-strike'
|
|
12
|
+
import { Subscript } from '@tiptap/extension-subscript'
|
|
13
|
+
import { Superscript } from '@tiptap/extension-superscript'
|
|
14
|
+
import { Text } from '@tiptap/extension-text'
|
|
15
|
+
import { Underline } from '@tiptap/extension-underline'
|
|
16
|
+
import MenuBubble from '@tiptapify/components/MenuBubble.vue'
|
|
17
|
+
|
|
18
|
+
// jsdom has no layout of a range, which the menu position reads
|
|
19
|
+
if (!Range.prototype.getClientRects) {
|
|
20
|
+
Range.prototype.getClientRects = () => [] as unknown as DOMRectList
|
|
21
|
+
Range.prototype.getBoundingClientRect = () => new DOMRect()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let app: App | null = null
|
|
25
|
+
let editor: Editor | null = null
|
|
26
|
+
|
|
27
|
+
async function flush() {
|
|
28
|
+
for (let i = 0; i < 5; i++) {
|
|
29
|
+
await nextTick()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function mountMenu(): Promise<HTMLElement> {
|
|
34
|
+
editor = new Editor({
|
|
35
|
+
element: document.body.appendChild(document.createElement('div')),
|
|
36
|
+
content: '<p><strong>Bold</strong> text</p>',
|
|
37
|
+
extensions: [Document, Paragraph, Text, Bold, Italic, Underline, Strike, Superscript, Subscript, Highlight, Code],
|
|
38
|
+
})
|
|
39
|
+
// The menu shows for a selection in the focused editor
|
|
40
|
+
editor.commands.focus()
|
|
41
|
+
editor.commands.setTextSelection({ from: 1, to: 5 })
|
|
42
|
+
|
|
43
|
+
app = createApp({
|
|
44
|
+
setup() {
|
|
45
|
+
provide('tiptapifyEditor', shallowRef(editor))
|
|
46
|
+
provide('tiptapifyI18n', { t: (key: string) => key })
|
|
47
|
+
provide('tiptapifyScrollContainer', ref(document.body))
|
|
48
|
+
|
|
49
|
+
return () => h(MenuBubble)
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
app.use(createVuetify())
|
|
53
|
+
app.mount(document.body.appendChild(document.createElement('div')))
|
|
54
|
+
await flush()
|
|
55
|
+
|
|
56
|
+
return document.querySelector<HTMLElement>('.tiptapify-bubble-menu')!
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
app?.unmount()
|
|
61
|
+
editor?.destroy()
|
|
62
|
+
app = null
|
|
63
|
+
editor = null
|
|
64
|
+
document.body.innerHTML = ''
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('selection bubble menu', () => {
|
|
68
|
+
it('is one row of icon buttons in groups split by dividers', async () => {
|
|
69
|
+
const menu = await mountMenu()
|
|
70
|
+
const children = [...menu.children].map(child => (child.classList.contains('tiptapify-bubble-divider') ? '|' : 'btn'))
|
|
71
|
+
|
|
72
|
+
expect(menu.classList).toContain('tiptapify-bubble-row')
|
|
73
|
+
expect(menu.querySelector('.v-card, .v-toolbar, .v-btn-group')).toBeNull()
|
|
74
|
+
expect(children.join(' ')).toBe('btn btn btn btn | btn btn | btn btn | btn | btn')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('marks the active marks of the selection as pressed', async () => {
|
|
78
|
+
const menu = await mountMenu()
|
|
79
|
+
const pressed = [...menu.querySelectorAll('.v-btn')].map(button => button.classList.contains('tiptapify-btn--active'))
|
|
80
|
+
|
|
81
|
+
expect(pressed).toEqual([true, false, false, false, false, false, false, false, false, false])
|
|
82
|
+
expect(menu.querySelector('.v-btn')!.getAttribute('aria-pressed')).toBe('true')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('applies a mark from its button', async () => {
|
|
86
|
+
const menu = await mountMenu()
|
|
87
|
+
|
|
88
|
+
;(menu.querySelectorAll<HTMLButtonElement>('.v-btn')[1]).click()
|
|
89
|
+
// The reactive state of the vue editor follows a transaction on the next frame
|
|
90
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
91
|
+
await flush()
|
|
92
|
+
|
|
93
|
+
expect(editor!.getHTML()).toBe('<p><strong><em>Bold</em></strong> text</p>')
|
|
94
|
+
expect(document.querySelectorAll('.tiptapify-bubble-menu .v-btn')[1].classList).toContain('tiptapify-btn--active')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('selection bubble menu indexes', () => {
|
|
99
|
+
it('applies both indexes to the selection, each from its button', async () => {
|
|
100
|
+
const menu = await mountMenu()
|
|
101
|
+
const buttons = () => document.querySelectorAll<HTMLButtonElement>('.tiptapify-bubble-menu .v-btn')
|
|
102
|
+
|
|
103
|
+
expect(menu.querySelectorAll('.v-btn')[4].getAttribute('aria-pressed')).toBe('false')
|
|
104
|
+
|
|
105
|
+
buttons()[4].click()
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
107
|
+
await flush()
|
|
108
|
+
|
|
109
|
+
expect(editor!.getHTML()).toBe('<p><strong><sup>Bold</sup></strong> text</p>')
|
|
110
|
+
expect(buttons()[4].classList).toContain('tiptapify-btn--active')
|
|
111
|
+
|
|
112
|
+
buttons()[5].click()
|
|
113
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
114
|
+
await flush()
|
|
115
|
+
|
|
116
|
+
expect(editor!.getHTML()).toBe('<p><strong><sup><sub>Bold</sub></sup></strong> text</p>')
|
|
117
|
+
expect(buttons()[4].classList).toContain('tiptapify-btn--active')
|
|
118
|
+
expect(buttons()[5].classList).toContain('tiptapify-btn--active')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -1,96 +1,127 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { Editor } from '@tiptap/vue-3'
|
|
3
|
-
import {
|
|
3
|
+
import { 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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
variant: { type: String, default () { return 'flat' } },
|
|
11
|
-
theme: { type: String, default () { return 'light' } },
|
|
12
|
-
})
|
|
7
|
+
import BubbleRow from '@tiptapify/components/UI/BubbleRow.vue'
|
|
8
|
+
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
9
|
+
import { ComposerTranslation } from 'vue-i18n'
|
|
13
10
|
|
|
14
11
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
15
12
|
|
|
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
13
|
// The menu follows its text or image when the editor scrolls inside its own box; it is created once the
|
|
21
14
|
// box is there, as the menu reads its options only when it is created
|
|
22
15
|
const scrollTarget = inject<Ref<HTMLElement | null>>('tiptapifyScrollContainer', ref(null))
|
|
23
16
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
17
|
+
type BubbleItem = {
|
|
18
|
+
name: string,
|
|
19
|
+
icon: string,
|
|
20
|
+
label: string,
|
|
21
|
+
hotkey?: string,
|
|
22
|
+
active?: () => boolean,
|
|
23
|
+
disabled?: () => boolean,
|
|
24
|
+
click: () => void,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
28
|
+
|
|
29
|
+
const groups: BubbleItem[][] = [
|
|
30
|
+
[
|
|
31
|
+
{
|
|
32
|
+
name: 'bold',
|
|
33
|
+
icon: mdi.mdiFormatBold,
|
|
34
|
+
label: 'format.bold',
|
|
35
|
+
hotkey: 'Mod-b',
|
|
36
|
+
active: () => editor.value.isActive('bold'),
|
|
37
|
+
disabled: () => !editor.value.can().chain().focus().toggleBold().run(),
|
|
38
|
+
click: () => editor.value.chain().focus().toggleBold().run(),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'italic',
|
|
42
|
+
icon: mdi.mdiFormatItalic,
|
|
43
|
+
label: 'format.italic',
|
|
44
|
+
hotkey: 'Mod-i',
|
|
45
|
+
active: () => editor.value.isActive('italic'),
|
|
46
|
+
disabled: () => !editor.value.can().chain().focus().toggleItalic().run(),
|
|
47
|
+
click: () => editor.value.chain().focus().toggleItalic().run(),
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
name: 'underline',
|
|
51
|
+
icon: mdi.mdiFormatUnderline,
|
|
52
|
+
label: 'format.underline',
|
|
53
|
+
hotkey: 'Mod-u',
|
|
54
|
+
active: () => editor.value.isActive('underline'),
|
|
55
|
+
disabled: () => !editor.value.can().chain().focus().toggleUnderline().run(),
|
|
56
|
+
click: () => editor.value.chain().focus().toggleUnderline().run(),
|
|
31
57
|
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
disabled:
|
|
39
|
-
|
|
58
|
+
{
|
|
59
|
+
name: 'strike',
|
|
60
|
+
icon: mdi.mdiFormatStrikethroughVariant,
|
|
61
|
+
label: 'format.strike',
|
|
62
|
+
hotkey: 'Mod-Shift-s',
|
|
63
|
+
active: () => editor.value.isActive('strike'),
|
|
64
|
+
disabled: () => !editor.value.can().chain().focus().toggleStrike().run(),
|
|
65
|
+
click: () => editor.value.chain().focus().toggleStrike().run(),
|
|
40
66
|
},
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
{
|
|
70
|
+
name: 'superscript',
|
|
71
|
+
icon: mdi.mdiFormatSuperscript,
|
|
72
|
+
label: 'format.sup',
|
|
73
|
+
hotkey: 'Mod-.',
|
|
74
|
+
active: () => editor.value.isActive('superscript'),
|
|
75
|
+
disabled: () => !editor.value.can().chain().focus().toggleSuperscript().run(),
|
|
76
|
+
click: () => editor.value.chain().focus().toggleSuperscript().run(),
|
|
49
77
|
},
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
disabled:
|
|
57
|
-
|
|
78
|
+
{
|
|
79
|
+
name: 'subscript',
|
|
80
|
+
icon: mdi.mdiFormatSubscript,
|
|
81
|
+
label: 'format.sub',
|
|
82
|
+
hotkey: 'Mod-,',
|
|
83
|
+
active: () => editor.value.isActive('subscript'),
|
|
84
|
+
disabled: () => !editor.value.can().chain().focus().toggleSubscript().run(),
|
|
85
|
+
click: () => editor.value.chain().focus().toggleSubscript().run(),
|
|
58
86
|
},
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
{
|
|
90
|
+
name: 'highlight',
|
|
91
|
+
icon: mdi.mdiFormatColorHighlight,
|
|
92
|
+
label: 'style.color.highlight',
|
|
93
|
+
active: () => editor.value.isActive('highlight'),
|
|
94
|
+
disabled: () => !editor.value.can().chain().focus().toggleHighlight().run(),
|
|
95
|
+
click: () => editor.value.chain().focus().toggleHighlight().run(),
|
|
67
96
|
},
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
disabled:
|
|
75
|
-
|
|
97
|
+
{
|
|
98
|
+
name: 'code',
|
|
99
|
+
icon: mdi.mdiCodeTags,
|
|
100
|
+
label: 'format.code',
|
|
101
|
+
hotkey: 'Mod-e',
|
|
102
|
+
active: () => editor.value.isActive('code'),
|
|
103
|
+
disabled: () => !editor.value.can().chain().focus().toggleCode().run(),
|
|
104
|
+
click: () => editor.value.chain().focus().toggleCode().run(),
|
|
76
105
|
},
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
],
|
|
107
|
+
[
|
|
108
|
+
{
|
|
109
|
+
name: 'link',
|
|
110
|
+
icon: mdi.mdiLink,
|
|
111
|
+
label: 'media.link',
|
|
112
|
+
active: () => editor.value.isActive('link'),
|
|
113
|
+
click: () => editor.value.commands.showLink(),
|
|
85
114
|
},
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
],
|
|
116
|
+
[
|
|
117
|
+
{
|
|
118
|
+
name: 'format clear',
|
|
119
|
+
icon: mdi.mdiFormatClear,
|
|
120
|
+
label: 'format.formatClear',
|
|
121
|
+
click: () => editor.value.chain().focus().unsetAllMarks().clearNodes().run(),
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
]
|
|
94
125
|
</script>
|
|
95
126
|
|
|
96
127
|
<template>
|
|
@@ -114,65 +145,24 @@ const items = ref([
|
|
|
114
145
|
return (from !== to) && !isAllSelected
|
|
115
146
|
}"
|
|
116
147
|
>
|
|
117
|
-
<
|
|
118
|
-
v-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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>
|
|
148
|
+
<BubbleRow class="tiptapify-bubble-menu">
|
|
149
|
+
<template v-for="(group, index) in groups" :key="index">
|
|
150
|
+
<div v-if="index > 0" class="tiptapify-bubble-divider" />
|
|
151
|
+
<VBtn
|
|
152
|
+
v-for="item in group"
|
|
153
|
+
:key="item.name"
|
|
154
|
+
size="32"
|
|
155
|
+
variant="text"
|
|
156
|
+
color=""
|
|
157
|
+
:disabled="item.disabled?.()"
|
|
158
|
+
:class="{ 'tiptapify-btn--active': item.active?.() }"
|
|
159
|
+
:aria-pressed="item.active ? item.active() : undefined"
|
|
160
|
+
@click="item.click"
|
|
161
|
+
>
|
|
162
|
+
<Tooltip :label="t(item.label)" :hotkey="item.hotkey" />
|
|
163
|
+
<VIcon :icon="`mdiSvg:${item.icon}`" size="20" />
|
|
164
|
+
</VBtn>
|
|
165
|
+
</template>
|
|
166
|
+
</BubbleRow>
|
|
152
167
|
</BubbleMenu>
|
|
153
168
|
</template>
|
|
154
|
-
|
|
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
|
-
|
|
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));
|
|
177
|
-
}
|
|
178
|
-
</style>
|
|
@@ -3,56 +3,59 @@ import { Editor } from '@tiptap/vue-3'
|
|
|
3
3
|
import { inject, Ref } from 'vue'
|
|
4
4
|
import { FloatingMenu } from '@tiptap/vue-3/menus'
|
|
5
5
|
import * as mdi from '@mdi/js'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
theme: { type: String, default () { return 'light' } },
|
|
10
|
-
})
|
|
6
|
+
import BubbleRow from '@tiptapify/components/UI/BubbleRow.vue'
|
|
7
|
+
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
8
|
+
import { ComposerTranslation } from 'vue-i18n'
|
|
11
9
|
|
|
12
10
|
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
11
|
+
|
|
12
|
+
const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
13
|
+
|
|
14
|
+
// What an empty line may become
|
|
15
|
+
const items = [
|
|
16
|
+
{
|
|
17
|
+
name: 'h1',
|
|
18
|
+
icon: mdi.mdiFormatHeader1,
|
|
19
|
+
label: 'style.headings.h1',
|
|
20
|
+
hotkey: 'Mod-Alt-1',
|
|
21
|
+
active: () => editor.value.isActive('heading', { level: 1 }),
|
|
22
|
+
click: () => editor.value.chain().focus().toggleHeading({ level: 1 }).run(),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'h2',
|
|
26
|
+
icon: mdi.mdiFormatHeader2,
|
|
27
|
+
label: 'style.headings.h2',
|
|
28
|
+
hotkey: 'Mod-Alt-2',
|
|
29
|
+
active: () => editor.value.isActive('heading', { level: 2 }),
|
|
30
|
+
click: () => editor.value.chain().focus().toggleHeading({ level: 2 }).run(),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'bulletList',
|
|
34
|
+
icon: mdi.mdiFormatListBulleted,
|
|
35
|
+
label: 'lists.bullet',
|
|
36
|
+
hotkey: 'Mod-Shift-8',
|
|
37
|
+
active: () => editor.value.isActive('bulletList'),
|
|
38
|
+
click: () => editor.value.chain().focus().toggleBulletList().run(),
|
|
39
|
+
},
|
|
40
|
+
]
|
|
13
41
|
</script>
|
|
14
42
|
|
|
15
43
|
<template>
|
|
16
44
|
<FloatingMenu v-if="editor" :editor="editor">
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
size="small"
|
|
33
|
-
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
|
34
|
-
>
|
|
35
|
-
<VIcon :icon="mdi['mdiFormatHeader2']" size="16" />
|
|
36
|
-
</VBtn>
|
|
37
|
-
<VBtn
|
|
38
|
-
:color="`${editor.isActive('bulletList') ? 'primary' : ''}`"
|
|
39
|
-
size="small"
|
|
40
|
-
@click="editor.chain().focus().toggleBulletList().run()"
|
|
41
|
-
>
|
|
42
|
-
<VIcon :icon="mdi['mdiFormatListBulleted']" size="16" />
|
|
43
|
-
</VBtn>
|
|
44
|
-
</VBtnGroup>
|
|
45
|
-
</VToolbarItems>
|
|
46
|
-
</VToolbar>
|
|
47
|
-
</VCardText>
|
|
48
|
-
</VCard>
|
|
49
|
-
</div>
|
|
45
|
+
<BubbleRow class="floating-menu">
|
|
46
|
+
<VBtn
|
|
47
|
+
v-for="item in items"
|
|
48
|
+
:key="item.name"
|
|
49
|
+
size="32"
|
|
50
|
+
variant="text"
|
|
51
|
+
color=""
|
|
52
|
+
:class="{ 'tiptapify-btn--active': item.active() }"
|
|
53
|
+
:aria-pressed="item.active()"
|
|
54
|
+
@click="item.click"
|
|
55
|
+
>
|
|
56
|
+
<Tooltip :label="t(item.label)" :hotkey="item.hotkey" />
|
|
57
|
+
<VIcon :icon="`mdiSvg:${item.icon}`" size="20" />
|
|
58
|
+
</VBtn>
|
|
59
|
+
</BubbleRow>
|
|
50
60
|
</FloatingMenu>
|
|
51
61
|
</template>
|
|
52
|
-
|
|
53
|
-
<style scoped lang="scss">
|
|
54
|
-
.floating-menu {
|
|
55
|
-
border-radius: 6px;
|
|
56
|
-
box-shadow: 4px 4px 20px var(--dark-gray);
|
|
57
|
-
}
|
|
58
|
-
</style>
|