tiptapify 0.3.5 → 0.4.1
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 +110770 -65623
- package/dist/tiptapify.umd.js +106 -89
- package/index.d.ts +37 -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 +210 -0
- package/src/extensions/components/media/image/imageDialog.ts +117 -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 +10 -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 +47 -2
- package/src/utils/validateHref.ts +47 -7
|
@@ -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>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import Tiptapify from '@tiptapify/components/Tiptapify.vue'
|
|
3
|
+
|
|
4
|
+
describe('Tiptapify upload prop', () => {
|
|
5
|
+
it('defaults to no upload endpoint', () => {
|
|
6
|
+
const props = (Tiptapify as unknown as { props: Record<string, { default: unknown }> }).props
|
|
7
|
+
|
|
8
|
+
expect(props.upload.default).toBeNull()
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -11,23 +11,29 @@ import {
|
|
|
11
11
|
TiptapifyAiResolvedOptions,
|
|
12
12
|
TiptapifyEditor,
|
|
13
13
|
TiptapifyFooterAlignment,
|
|
14
|
+
TiptapifyLinkPreviewOptions,
|
|
14
15
|
TiptapifySkinInput,
|
|
16
|
+
TiptapifyUploadOptions,
|
|
15
17
|
variantBtnTypes,
|
|
16
18
|
variantFieldTypes
|
|
17
19
|
} from '@tiptapify/types/editor'
|
|
18
20
|
import MenuBubble from '@tiptapify/components/MenuBubble.vue'
|
|
19
21
|
import MenuFloating from '@tiptapify/components/MenuFloating.vue'
|
|
20
22
|
import ImageBubbleMenu from '@tiptapify/components/ImageBubbleMenu.vue'
|
|
23
|
+
import PasteCardMenu from '@tiptapify/extensions/components/media/linkCard/PasteCardMenu.vue'
|
|
21
24
|
|
|
22
25
|
import { useI18n } from 'vue-i18n'
|
|
23
26
|
|
|
24
27
|
import { getTiptapEditor } from '@tiptapify/components/index'
|
|
25
|
-
import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
|
|
28
|
+
import { skinRootClasses, TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
|
|
29
|
+
import { TIPTAPIFY_UPLOAD_KEY } from '@tiptapify/composables/useTiptapifyUpload'
|
|
30
|
+
import { createLinkPreviewState, TIPTAPIFY_LINK_PREVIEW_KEY } from '@tiptapify/composables/useTiptapifyLinkPreview'
|
|
26
31
|
import { resolveSkin } from '@tiptapify/skins/registry'
|
|
27
32
|
import { createAiBackendProvider } from '@tiptapify/extensions/components/ai/backend'
|
|
28
33
|
|
|
29
34
|
import Footer from '@tiptapify/components/Footer.vue'
|
|
30
35
|
import { useTheme } from 'vuetify/framework'
|
|
36
|
+
import { contentCss, contentHTML, TiptapifyContentStyles } from '@tiptapify/utils/contentStyles'
|
|
31
37
|
|
|
32
38
|
const props = defineProps({
|
|
33
39
|
locale: { type: String, default () { return 'en' } },
|
|
@@ -63,6 +69,14 @@ const props = defineProps({
|
|
|
63
69
|
imageSlider: { type: Number, default() { return 0 } },
|
|
64
70
|
// The skin of the editor chrome: a registered name ('default', 'pastel') or an inline skin object
|
|
65
71
|
skin: { type: [String, Object] as PropType<TiptapifySkinInput>, default() { return defaults.skin } },
|
|
72
|
+
// The upload endpoint of files picked in the image and video dialogs; without it a picked image is
|
|
73
|
+
// embedded as base64 and a video is inserted by its link only
|
|
74
|
+
upload: { type: Object as PropType<TiptapifyUploadOptions | null>, default: null },
|
|
75
|
+
// The backend endpoint of link cards (OpenGraph data of pages); without it there are no link cards
|
|
76
|
+
linkPreview: { type: Object as PropType<TiptapifyLinkPreviewOptions | null>, default: null },
|
|
77
|
+
// How the saved HTML carries the look of its content: `inline` styles, or `classes` for a site that loads
|
|
78
|
+
// tiptapify/content.css
|
|
79
|
+
contentStyles: { type: String as PropType<TiptapifyContentStyles>, default: 'inline' },
|
|
66
80
|
})
|
|
67
81
|
|
|
68
82
|
const emit = defineEmits(['update:modelValue', 'editor-ready', 'content-changed'])
|
|
@@ -121,7 +135,7 @@ const tiptapifyAi = computed(() => {
|
|
|
121
135
|
})
|
|
122
136
|
|
|
123
137
|
function contentChanged() {
|
|
124
|
-
const html = editor.value?.getHTML() ?? ''
|
|
138
|
+
const html = contentHTML(editor.value?.getHTML() ?? '', props.contentStyles)
|
|
125
139
|
emit('update:modelValue', html)
|
|
126
140
|
emit('content-changed', { html, json: editor.value?.getJSON() })
|
|
127
141
|
}
|
|
@@ -145,7 +159,7 @@ const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
|
|
|
145
159
|
(<TiptapifyEditor>editorInstance).interactiveStyles = props.interactiveStyles
|
|
146
160
|
emit('editor-ready', {
|
|
147
161
|
editor: editorInstance,
|
|
148
|
-
getHTML: () => editorInstance.getHTML(),
|
|
162
|
+
getHTML: () => contentHTML(editorInstance.getHTML(), props.contentStyles),
|
|
149
163
|
getJSON: () => editorInstance.getJSON(),
|
|
150
164
|
})
|
|
151
165
|
},
|
|
@@ -153,34 +167,33 @@ const editor: ShallowRef<Editor | undefined> = getTiptapEditor(
|
|
|
153
167
|
captionPlaceholder: t('dialog.image.caption_placeholder'),
|
|
154
168
|
sliderMinImages: props.imageSlider,
|
|
155
169
|
sliderLabel: t('media.slider'),
|
|
170
|
+
// Without an upload endpoint the image dialog embeds a picked file as base64
|
|
171
|
+
allowBase64: !props.upload?.endpoint,
|
|
156
172
|
},
|
|
157
173
|
)
|
|
158
174
|
|
|
159
175
|
provide('tiptapifyEditor', editor)
|
|
160
176
|
|
|
177
|
+
// The editor shows its content with the content styles, under its own `.tiptap` (the HTML preview too)
|
|
178
|
+
const CONTENT_STYLE_ID = 'tiptapify-content-styles'
|
|
179
|
+
if (typeof document !== 'undefined' && !document.getElementById(CONTENT_STYLE_ID)) {
|
|
180
|
+
const style = document.createElement('style')
|
|
181
|
+
style.id = CONTENT_STYLE_ID
|
|
182
|
+
style.textContent = contentCss('.tiptap', { weightless: false })
|
|
183
|
+
document.head.append(style)
|
|
184
|
+
}
|
|
185
|
+
|
|
161
186
|
const scrollContainer = ref<HTMLElement | null>(null)
|
|
162
187
|
provide('tiptapifyScrollContainer', scrollContainer)
|
|
163
188
|
provide('tiptapifyI18n', { t })
|
|
164
189
|
provide('tiptapifyAi', tiptapifyAi)
|
|
190
|
+
provide(TIPTAPIFY_UPLOAD_KEY, toRef(props, 'upload'))
|
|
191
|
+
provide(TIPTAPIFY_LINK_PREVIEW_KEY, createLinkPreviewState(toRef(props, 'linkPreview')))
|
|
165
192
|
// The skin prop resolves through the registry; computed refs so a prop change
|
|
166
193
|
// re-resolves tokens and shape without an editor re-render
|
|
167
194
|
const resolvedSkin = computed(() => resolveSkin(props.skin))
|
|
168
195
|
|
|
169
|
-
|
|
170
|
-
// shape (not on the skin name); templates read the computed refs. The name class stays
|
|
171
|
-
// as the escape hatch for consumer CSS.
|
|
172
|
-
const shapeClasses = computed(() => {
|
|
173
|
-
const shape = resolvedSkin.value.shape
|
|
174
|
-
|
|
175
|
-
return [
|
|
176
|
-
`tiptapify-shape-toolbar-${shape.toolbar}`,
|
|
177
|
-
`tiptapify-shape-groups-${shape.groups}`,
|
|
178
|
-
`tiptapify-shape-buttons-${shape.buttons}`,
|
|
179
|
-
`tiptapify-shape-bubble-${shape.bubble}`,
|
|
180
|
-
`tiptapify-shape-dialog-${shape.dialog}`,
|
|
181
|
-
`tiptapify-shape-menu-${shape.menu}`,
|
|
182
|
-
].join(' ')
|
|
183
|
-
})
|
|
196
|
+
const rootClasses = computed(() => skinRootClasses(resolvedSkin.value.className, resolvedSkin.value.shape))
|
|
184
197
|
|
|
185
198
|
provide(TIPTAPIFY_CONFIG_KEY, {
|
|
186
199
|
variantBtn: toRef(props, 'variantBtn'),
|
|
@@ -189,6 +202,7 @@ provide(TIPTAPIFY_CONFIG_KEY, {
|
|
|
189
202
|
skinName: computed(() => resolvedSkin.value.name),
|
|
190
203
|
tokens: computed(() => resolvedSkin.value.tokens),
|
|
191
204
|
shape: computed(() => resolvedSkin.value.shape),
|
|
205
|
+
rootClasses,
|
|
192
206
|
})
|
|
193
207
|
|
|
194
208
|
editor.value?.chain().setFontFamily(props.defaultFontFamily).run()
|
|
@@ -210,7 +224,7 @@ onBeforeUnmount(() => {
|
|
|
210
224
|
<template>
|
|
211
225
|
<div
|
|
212
226
|
:id="`tiptapify-editor-${editor?.instanceId}`"
|
|
213
|
-
:class="
|
|
227
|
+
:class="rootClasses"
|
|
214
228
|
:style="resolvedSkin.tokens"
|
|
215
229
|
>
|
|
216
230
|
<div :class="`tiptapify-frame rounded-${rounded}`">
|
|
@@ -230,12 +244,14 @@ onBeforeUnmount(() => {
|
|
|
230
244
|
<VProgressLinear v-model="loadingProgress" :color="loadingColor" :height="loadingHeight" :indeterminate="loading" />
|
|
231
245
|
|
|
232
246
|
<div ref="scrollContainer" class="pa-2 tiptapify-container resizable" :style="computeResizableHeight" @click="onContainerClick">
|
|
233
|
-
<MenuFloating v-if="floatingMenu"
|
|
247
|
+
<MenuFloating v-if="floatingMenu" />
|
|
234
248
|
|
|
235
|
-
<MenuBubble v-if="bubbleMenu"
|
|
249
|
+
<MenuBubble v-if="bubbleMenu" />
|
|
236
250
|
|
|
237
251
|
<ImageBubbleMenu />
|
|
238
252
|
|
|
253
|
+
<PasteCardMenu />
|
|
254
|
+
|
|
239
255
|
<EditorContent :editor="editor" class="tiptapify-editor" />
|
|
240
256
|
</div>
|
|
241
257
|
|
|
@@ -370,34 +386,15 @@ onBeforeUnmount(() => {
|
|
|
370
386
|
}
|
|
371
387
|
}
|
|
372
388
|
|
|
373
|
-
/* Task
|
|
389
|
+
/* Task lists: the layout is the content styles (utils/contentStyles.ts); the editor adds the pointer */
|
|
374
390
|
ul[data-type='taskList'] {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
padding: 0;
|
|
378
|
-
|
|
379
|
-
li {
|
|
380
|
-
align-items: flex-start;
|
|
381
|
-
display: flex;
|
|
382
|
-
|
|
383
|
-
> label {
|
|
384
|
-
flex: 0 0 auto;
|
|
385
|
-
margin-right: 0.5rem;
|
|
386
|
-
user-select: none;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
> div {
|
|
390
|
-
flex: 1 1 auto;
|
|
391
|
-
}
|
|
391
|
+
li > label {
|
|
392
|
+
user-select: none;
|
|
392
393
|
}
|
|
393
394
|
|
|
394
395
|
input[type='checkbox'] {
|
|
395
396
|
cursor: pointer;
|
|
396
397
|
}
|
|
397
|
-
|
|
398
|
-
ul[data-type='taskList'] {
|
|
399
|
-
margin: 0;
|
|
400
|
-
}
|
|
401
398
|
}
|
|
402
399
|
|
|
403
400
|
/* Heading styles */
|
|
@@ -598,6 +595,41 @@ onBeforeUnmount(() => {
|
|
|
598
595
|
display: block;
|
|
599
596
|
}
|
|
600
597
|
|
|
598
|
+
/*
|
|
599
|
+
* While editing, a click on a player or an embed selects its block (a double click opens the video
|
|
600
|
+
* or the embed dialog)
|
|
601
|
+
*/
|
|
602
|
+
&[contenteditable='true'] {
|
|
603
|
+
div[data-youtube-video],
|
|
604
|
+
.iframe-wrapper,
|
|
605
|
+
.tiptapify-video {
|
|
606
|
+
cursor: pointer;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
div[data-youtube-video] iframe,
|
|
610
|
+
.iframe-wrapper iframe,
|
|
611
|
+
.tiptapify-video video {
|
|
612
|
+
pointer-events: none;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
div[data-youtube-video].ProseMirror-selectednode iframe,
|
|
617
|
+
.iframe-wrapper.ProseMirror-selectednode iframe,
|
|
618
|
+
.tiptapify-video.ProseMirror-selectednode video {
|
|
619
|
+
outline: 2px solid rgb(var(--v-theme-primary));
|
|
620
|
+
outline-offset: 2px;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/* Link cards: their look is the content styles; the editor only adds the selection */
|
|
624
|
+
.tiptapify-link-card {
|
|
625
|
+
cursor: pointer;
|
|
626
|
+
|
|
627
|
+
&.ProseMirror-selectednode {
|
|
628
|
+
outline: 2px solid rgb(var(--v-theme-primary));
|
|
629
|
+
outline-offset: 2px;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
601
633
|
/* Image with the caption under it (see extensions/components/media/image/caption.ts) */
|
|
602
634
|
.tiptapify-image {
|
|
603
635
|
--tiptapify-image-max-height: 400px;
|
|
@@ -608,7 +640,10 @@ onBeforeUnmount(() => {
|
|
|
608
640
|
max-width: 100%;
|
|
609
641
|
margin: 1rem 0;
|
|
610
642
|
|
|
611
|
-
/*
|
|
643
|
+
/*
|
|
644
|
+
* Never wider than the text; without a set width (dialog or resizing) never taller than the max height
|
|
645
|
+
* either, so big photos stay out of the way
|
|
646
|
+
*/
|
|
612
647
|
img {
|
|
613
648
|
max-width: 100%;
|
|
614
649
|
height: auto !important;
|
|
@@ -616,12 +651,16 @@ onBeforeUnmount(() => {
|
|
|
616
651
|
}
|
|
617
652
|
|
|
618
653
|
[data-resize-wrapper] {
|
|
619
|
-
max-width:
|
|
654
|
+
max-width: 100%;
|
|
620
655
|
border-radius: var(--tf-image-radius, 0.125rem);
|
|
621
656
|
outline: 1px solid transparent;
|
|
622
657
|
transition: outline-color 0.15s;
|
|
623
658
|
}
|
|
624
659
|
|
|
660
|
+
&:not([data-sized]) [data-resize-wrapper] {
|
|
661
|
+
max-width: min(100%, calc(var(--tiptapify-image-max-height) * var(--tiptapify-image-ratio, 100)));
|
|
662
|
+
}
|
|
663
|
+
|
|
625
664
|
/* One block: hovering outlines it, a click selects it and shows its toolbar */
|
|
626
665
|
img {
|
|
627
666
|
cursor: pointer;
|
|
@@ -862,6 +901,7 @@ onBeforeUnmount(() => {
|
|
|
862
901
|
|
|
863
902
|
.tiptapify-dialog-actions {
|
|
864
903
|
display: flex;
|
|
904
|
+
flex: 1 1 auto;
|
|
865
905
|
flex-wrap: wrap;
|
|
866
906
|
align-items: center;
|
|
867
907
|
justify-content: space-between;
|
|
@@ -366,6 +366,16 @@ toolbarItems = toolbarItems
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
/*
|
|
370
|
+
* Classic active buttons (`color="primary"` -> `.bg-primary`): Vuetify 4 puts its utilities in a cascade
|
|
371
|
+
* layer under the button variant's background, so this unlayered rule restores the fill. The quiet buttons
|
|
372
|
+
* above keep their own pressed look (their rules carry `!important`).
|
|
373
|
+
*/
|
|
374
|
+
.tiptapify-toolbar .v-btn.bg-primary {
|
|
375
|
+
background-color: rgb(var(--v-theme-primary));
|
|
376
|
+
color: rgb(var(--v-theme-on-primary));
|
|
377
|
+
}
|
|
378
|
+
|
|
369
379
|
/* A toolbar button that opens a dropdown: the current choice and a chevron */
|
|
370
380
|
.tiptapify-toolbar .v-btn.tiptapify-dropdown-button {
|
|
371
381
|
min-width: 0;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
3
|
+
|
|
4
|
+
// The surface of the menus floating over the text (selection, empty line, image): one row of 32px icon
|
|
5
|
+
// buttons in groups split by `.tiptapify-bubble-divider`; an active button carries `.tiptapify-btn--active`
|
|
6
|
+
const { shape } = useTiptapifyConfig()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<div class="tiptapify-bubble-row" :class="{ 'tiptapify-quiet-buttons': shape.buttons === 'quiet' }">
|
|
11
|
+
<slot />
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<style scoped lang="scss">
|
|
16
|
+
/* The fallbacks are the classic shape's base: the default skin emits no bubble tokens */
|
|
17
|
+
.tiptapify-bubble-row {
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
gap: var(--tf-bubble-gap, 2px);
|
|
21
|
+
padding: var(--tf-bubble-padding, 4px);
|
|
22
|
+
border: var(--tf-bubble-border, none);
|
|
23
|
+
border-radius: var(--tf-bubble-radius, 6px);
|
|
24
|
+
background: var(--tf-bubble-bg, rgb(var(--v-theme-surface)));
|
|
25
|
+
box-shadow: var(--tf-bubble-shadow, 0 4px 14px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
:slotted(.tiptapify-bubble-divider) {
|
|
29
|
+
flex: none;
|
|
30
|
+
width: 1px;
|
|
31
|
+
height: 20px;
|
|
32
|
+
margin: 0 4px;
|
|
33
|
+
background: rgba(var(--v-border-color), var(--v-border-opacity));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* The classic pressed look: a primary tint (the quiet shape paints its own, Toolbar/Index.vue) */
|
|
37
|
+
.tiptapify-bubble-row:not(.tiptapify-quiet-buttons) :slotted(.v-btn.tiptapify-btn--active) {
|
|
38
|
+
background-color: rgba(var(--v-theme-primary), 0.12);
|
|
39
|
+
color: rgb(var(--v-theme-primary));
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, it } from 'vitest'
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
2
|
import { App, computed, createApp, h, nextTick, provide, Ref, ref } from 'vue'
|
|
3
3
|
import { createVuetify } from 'vuetify'
|
|
4
4
|
import { VTextField } from 'vuetify/components'
|
|
@@ -23,7 +23,7 @@ if (!('visualViewport' in globalThis)) {
|
|
|
23
23
|
// variant and the field defaults all key off `shape.dialog`, not the skin name.
|
|
24
24
|
const openApps: { app: App, host: HTMLElement }[] = []
|
|
25
25
|
|
|
26
|
-
async function openDialog (skinInput: unknown): Promise<HTMLElement> {
|
|
26
|
+
async function openDialog (skinInput: unknown, listeners: Record<string, () => void> = {}, slots: Record<string, () => unknown> = {}): Promise<HTMLElement> {
|
|
27
27
|
const config = resolveSkin(skinInput)
|
|
28
28
|
const dialog: Ref<{ open: () => void } | null> = ref(null)
|
|
29
29
|
|
|
@@ -39,8 +39,9 @@ async function openDialog (skinInput: unknown): Promise<HTMLElement> {
|
|
|
39
39
|
})
|
|
40
40
|
provide('tiptapifyI18n', { t: (key: string) => key })
|
|
41
41
|
|
|
42
|
-
return () => h(TiptapifyDialog, { ref: dialog, title: 'Probe dialog', module: 'link' }, {
|
|
42
|
+
return () => h(TiptapifyDialog, { ref: dialog, title: 'Probe dialog', module: 'link', ...listeners }, {
|
|
43
43
|
content: () => h('div', { class: 'probe-content' }, [h(VTextField, { label: 'Field' })]),
|
|
44
|
+
...slots,
|
|
44
45
|
})
|
|
45
46
|
},
|
|
46
47
|
})
|
|
@@ -130,3 +131,36 @@ describe('TiptapifyDialog shape wiring', () => {
|
|
|
130
131
|
expect(classic.querySelector('.tiptapify-dialog-header')?.nextElementSibling?.classList.contains('v-divider')).toBe(true)
|
|
131
132
|
})
|
|
132
133
|
})
|
|
134
|
+
|
|
135
|
+
describe('TiptapifyDialog form', () => {
|
|
136
|
+
it('submits on Enter in a field: the content is a form', async () => {
|
|
137
|
+
const onSubmit = vi.fn()
|
|
138
|
+
const card = await openDialog('default', { onSubmit })
|
|
139
|
+
|
|
140
|
+
card.querySelector('form')!.requestSubmit()
|
|
141
|
+
|
|
142
|
+
expect(onSubmit).toHaveBeenCalledOnce()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('tells the dialog it closed, however it was closed', async () => {
|
|
146
|
+
const onClosed = vi.fn()
|
|
147
|
+
const card = await openDialog('default', { onClosed })
|
|
148
|
+
|
|
149
|
+
card.querySelector<HTMLElement>('.tiptapify-dialog-close')!.click()
|
|
150
|
+
await nextTick()
|
|
151
|
+
|
|
152
|
+
expect(onClosed).toHaveBeenCalledOnce()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('TiptapifyDialog title', () => {
|
|
157
|
+
it('shows the title prop, or the title slot of the dialog', async () => {
|
|
158
|
+
const plain = await openDialog('default')
|
|
159
|
+
expect(plain.querySelector('.tiptapify-dialog-title')!.textContent!.trim()).toBe('Probe dialog')
|
|
160
|
+
|
|
161
|
+
disposeDialogs()
|
|
162
|
+
|
|
163
|
+
const custom = await openDialog('default', {}, { title: () => h('b', { class: 'probe-title' }, 'With icon') })
|
|
164
|
+
expect(custom.querySelector('.tiptapify-dialog-title .probe-title')!.textContent).toBe('With icon')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -23,10 +23,18 @@ const { shape, tokens } = useTiptapifyConfig()
|
|
|
23
23
|
const dialogSoft = computed(() => shape.value.dialog === 'soft')
|
|
24
24
|
|
|
25
25
|
defineExpose({ open, close })
|
|
26
|
-
|
|
26
|
+
// `submit`: Enter in a field or a submit button; `closed`: closed in any way (close button, click outside,
|
|
27
|
+
// Esc, close()), for the dialog to reset its form
|
|
28
|
+
const emits = defineEmits(['closeDialog', 'submit', 'closed'])
|
|
27
29
|
|
|
28
30
|
const showDialog = ref<boolean>(false)
|
|
29
31
|
|
|
32
|
+
watch(showDialog, (shown) => {
|
|
33
|
+
if (!shown) {
|
|
34
|
+
emits('closed')
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
|
|
30
38
|
const field = { variant: 'outlined', density: 'comfortable' }
|
|
31
39
|
const fieldDefaults = {
|
|
32
40
|
VTextField: field,
|
|
@@ -171,7 +179,8 @@ onBeforeUnmount(() => {
|
|
|
171
179
|
style="user-select: none;"
|
|
172
180
|
>
|
|
173
181
|
<span class="tiptapify-dialog-title">
|
|
174
|
-
|
|
182
|
+
<!-- A dialog may put an icon or more into its title -->
|
|
183
|
+
<slot name="title">{{ title ?? t(`dialog.${module}.title`) }}</slot>
|
|
175
184
|
</span>
|
|
176
185
|
|
|
177
186
|
<VSpacer />
|
|
@@ -193,9 +202,11 @@ onBeforeUnmount(() => {
|
|
|
193
202
|
|
|
194
203
|
<!-- In the soft dialog shape, fields a dialog does not style itself get the light outlined look -->
|
|
195
204
|
<VDefaultsProvider :defaults="dialogSoft ? fieldDefaults : {}">
|
|
196
|
-
<
|
|
205
|
+
<form class="tiptapify-dialog-form" novalidate @submit.prevent="emits('submit')">
|
|
206
|
+
<slot name="content" />
|
|
197
207
|
|
|
198
|
-
|
|
208
|
+
<slot name="actions" />
|
|
209
|
+
</form>
|
|
199
210
|
</VDefaultsProvider>
|
|
200
211
|
</VCard>
|
|
201
212
|
</VDialog>
|
|
@@ -7,4 +7,13 @@ describe('editor extensions', () => {
|
|
|
7
7
|
|
|
8
8
|
expect(link?.options).toMatchObject({ openOnClick: false, enableClickSelection: true })
|
|
9
9
|
})
|
|
10
|
+
|
|
11
|
+
it('keeps base64 images unless the host uploads images to its own server', () => {
|
|
12
|
+
const image = (allowBase64?: boolean) => editorExtensions('', false, [], null, { captionPlaceholder: '', sliderMinImages: 0, sliderLabel: '', allowBase64 })
|
|
13
|
+
.find(extension => extension.name === 'image')
|
|
14
|
+
|
|
15
|
+
expect(image()?.options.allowBase64).toBe(true)
|
|
16
|
+
expect(image(false)?.options.allowBase64).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
|
|
10
19
|
})
|
|
@@ -34,6 +34,9 @@ import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/c
|
|
|
34
34
|
import { ImageSlider } from '@tiptapify/extensions/components/media/image/slider'
|
|
35
35
|
import { TiptapifyLink } from '@tiptapify/extensions/components/media/link'
|
|
36
36
|
import { TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
|
|
37
|
+
import { VideoFile } from '@tiptapify/extensions/components/media/video/videoFile'
|
|
38
|
+
import { LinkCard } from '@tiptapify/extensions/components/media/linkCard'
|
|
39
|
+
import { LinkCardPaste } from '@tiptapify/extensions/components/media/linkCard/pasteCard'
|
|
37
40
|
import CodeBlockComponent from '@tiptapify/extensions/components/CodeBlockComponent.vue'
|
|
38
41
|
import SlashCommands, { SlashCommandsExtensionOptions } from '@tiptapify/extensions/slash-commands'
|
|
39
42
|
import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
|
|
@@ -59,10 +62,23 @@ export interface TiptapifyImageOptions {
|
|
|
59
62
|
// Images in a row from which the site shows a slider; 0 disables the slider grid
|
|
60
63
|
sliderMinImages: number
|
|
61
64
|
sliderLabel: string
|
|
65
|
+
// Images embedded as base64 data URLs: kept unless the host uploads images to its own server
|
|
66
|
+
allowBase64?: boolean
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
const defaultImageOptions: TiptapifyImageOptions = { captionPlaceholder: '', sliderMinImages: 0, sliderLabel: '' }
|
|
65
70
|
|
|
71
|
+
// No default size: a responsive video is saved without one and must load back responsive
|
|
72
|
+
const ResponsiveYoutube = Youtube.extend({
|
|
73
|
+
addAttributes() {
|
|
74
|
+
return {
|
|
75
|
+
...this.parent?.(),
|
|
76
|
+
width: { default: null },
|
|
77
|
+
height: { default: null },
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
66
82
|
export function editorExtensions (
|
|
67
83
|
placeholder: string,
|
|
68
84
|
slashCommands: SlashCommandsConfig,
|
|
@@ -112,17 +128,21 @@ export function editorExtensions (
|
|
|
112
128
|
directions: ['top', 'right', 'bottom', 'left', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
|
113
129
|
},
|
|
114
130
|
captionPlaceholder: imageOptions.captionPlaceholder,
|
|
131
|
+
allowBase64: imageOptions.allowBase64 ?? true,
|
|
115
132
|
}),
|
|
116
133
|
ImageSlider.configure({
|
|
117
134
|
minImages: imageOptions.sliderMinImages,
|
|
118
135
|
label: imageOptions.sliderLabel,
|
|
119
136
|
}),
|
|
120
|
-
|
|
137
|
+
ResponsiveYoutube.configure({
|
|
121
138
|
controls: true,
|
|
122
139
|
nocookie: true,
|
|
123
140
|
}),
|
|
124
141
|
TiptapifyImage,
|
|
125
142
|
TiptapifyVideo,
|
|
143
|
+
VideoFile,
|
|
144
|
+
LinkCard,
|
|
145
|
+
LinkCardPaste,
|
|
126
146
|
Iframe,
|
|
127
147
|
Superscript,
|
|
128
148
|
Subscript,
|