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
|
@@ -2,6 +2,7 @@ import { default as AlignmentLeftButton } from '@tiptapify/extensions/components
|
|
|
2
2
|
import { default as AlignmentCenterButton } from '@tiptapify/extensions/components/alignment/center/Button.vue'
|
|
3
3
|
import { default as AlignmentRightButton } from '@tiptapify/extensions/components/alignment/right/Button.vue'
|
|
4
4
|
import { default as AlignmentJustifyButton } from '@tiptapify/extensions/components/alignment/justify/Button.vue'
|
|
5
|
+
import { default as AlignmentMenuButton } from '@tiptapify/extensions/components/alignment/menu/Button.vue'
|
|
5
6
|
import { markRaw } from 'vue'
|
|
6
7
|
|
|
7
8
|
export default {
|
|
@@ -24,5 +25,9 @@ export default {
|
|
|
24
25
|
name: 'justify',
|
|
25
26
|
component: markRaw(AlignmentJustifyButton),
|
|
26
27
|
},
|
|
28
|
+
{
|
|
29
|
+
name: 'align',
|
|
30
|
+
component: markRaw(AlignmentMenuButton),
|
|
31
|
+
},
|
|
27
32
|
]
|
|
28
33
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { App, computed, createApp, h, nextTick, provide, Ref, ref } from 'vue'
|
|
3
|
+
import { createVuetify } from 'vuetify'
|
|
4
|
+
import { VTextField } from 'vuetify/components'
|
|
5
|
+
import TiptapifyDialog from '@tiptapify/components/UI/TiptapifyDialog.vue'
|
|
6
|
+
import { TIPTAPIFY_CONFIG_KEY } from '@tiptapify/composables/useTiptapifyConfig'
|
|
7
|
+
import { resolveSkin } from '@tiptapify/skins/registry'
|
|
8
|
+
|
|
9
|
+
// jsdom has no `visualViewport` global; Vuetify's overlay positioning reads it,
|
|
10
|
+
// and the `visualViewport?.` guards do not protect against an undeclared identifier
|
|
11
|
+
if (!('visualViewport' in globalThis)) {
|
|
12
|
+
;(globalThis as Record<string, unknown>).visualViewport = {
|
|
13
|
+
scale: 1,
|
|
14
|
+
offsetLeft: 0,
|
|
15
|
+
offsetTop: 0,
|
|
16
|
+
addEventListener: () => {},
|
|
17
|
+
removeEventListener: () => {},
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// The dialog teleports to the overlay root, so its skin markup rides the shape:
|
|
22
|
+
// the `tiptapify-dialog--soft` carrier, the divider under the title, the close
|
|
23
|
+
// variant and the field defaults all key off `shape.dialog`, not the skin name.
|
|
24
|
+
const openApps: { app: App, host: HTMLElement }[] = []
|
|
25
|
+
|
|
26
|
+
async function openDialog (skinInput: unknown): Promise<HTMLElement> {
|
|
27
|
+
const config = resolveSkin(skinInput)
|
|
28
|
+
const dialog: Ref<{ open: () => void } | null> = ref(null)
|
|
29
|
+
|
|
30
|
+
const app = createApp({
|
|
31
|
+
setup () {
|
|
32
|
+
provide(TIPTAPIFY_CONFIG_KEY, {
|
|
33
|
+
variantBtn: ref('flat'),
|
|
34
|
+
variantField: ref('outlined'),
|
|
35
|
+
skin: ref(skinInput),
|
|
36
|
+
skinName: computed(() => config.name),
|
|
37
|
+
tokens: computed(() => config.tokens),
|
|
38
|
+
shape: computed(() => config.shape),
|
|
39
|
+
})
|
|
40
|
+
provide('tiptapifyI18n', { t: (key: string) => key })
|
|
41
|
+
|
|
42
|
+
return () => h(TiptapifyDialog, { ref: dialog, title: 'Probe dialog', module: 'link' }, {
|
|
43
|
+
content: () => h('div', { class: 'probe-content' }, [h(VTextField, { label: 'Field' })]),
|
|
44
|
+
})
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
app.use(createVuetify())
|
|
48
|
+
|
|
49
|
+
const host = document.createElement('div')
|
|
50
|
+
document.body.appendChild(host)
|
|
51
|
+
app.mount(host)
|
|
52
|
+
openApps.push({ app, host })
|
|
53
|
+
await nextTick()
|
|
54
|
+
|
|
55
|
+
dialog.value?.open()
|
|
56
|
+
for (let i = 0; i < 10; i++) {
|
|
57
|
+
await nextTick()
|
|
58
|
+
if (document.querySelector('.tiptapify-dialog')) {
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const card = document.querySelector<HTMLElement>('.tiptapify-dialog')
|
|
64
|
+
if (!card) {
|
|
65
|
+
throw new Error('dialog card did not render')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return card
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function disposeDialogs () {
|
|
72
|
+
for (const { app, host } of openApps.splice(0)) {
|
|
73
|
+
app.unmount()
|
|
74
|
+
host.remove()
|
|
75
|
+
}
|
|
76
|
+
document.body.innerHTML = ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
afterEach(disposeDialogs)
|
|
80
|
+
|
|
81
|
+
describe('TiptapifyDialog shape wiring', () => {
|
|
82
|
+
it('the classic dialog keeps the divider, the elevated close and no soft carrier', async () => {
|
|
83
|
+
const card = await openDialog('default')
|
|
84
|
+
|
|
85
|
+
expect(card.classList.contains('tiptapify-dialog--soft')).toBe(false)
|
|
86
|
+
expect(card.classList.contains('tiptapify-dialog--pastel')).toBe(false)
|
|
87
|
+
|
|
88
|
+
const header = card.querySelector('.tiptapify-dialog-header')
|
|
89
|
+
expect(header?.nextElementSibling?.classList.contains('v-divider')).toBe(true)
|
|
90
|
+
|
|
91
|
+
const close = card.querySelector('.tiptapify-dialog-close')
|
|
92
|
+
expect([...close?.classList ?? []].join(' ')).toContain('v-btn--variant-elevated')
|
|
93
|
+
|
|
94
|
+
const input = card.querySelector('.v-input')
|
|
95
|
+
expect([...input?.classList ?? []].join(' ')).toContain('v-input--density-default')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('the pastel dialog carries tiptapify-dialog--soft, no divider, a text close and soft field defaults', async () => {
|
|
99
|
+
const card = await openDialog('pastel')
|
|
100
|
+
|
|
101
|
+
expect(card.classList.contains('tiptapify-dialog--soft')).toBe(true)
|
|
102
|
+
|
|
103
|
+
const header = card.querySelector('.tiptapify-dialog-header')
|
|
104
|
+
expect(header?.nextElementSibling?.classList.contains('v-divider')).toBe(false)
|
|
105
|
+
|
|
106
|
+
const close = card.querySelector('.tiptapify-dialog-close')
|
|
107
|
+
expect([...close?.classList ?? []].join(' ')).toContain('v-btn--variant-text')
|
|
108
|
+
|
|
109
|
+
// VDefaultsProvider: the soft dialog gives unstyled fields the comfortable outlined defaults
|
|
110
|
+
const input = card.querySelector('.v-input')
|
|
111
|
+
expect([...input?.classList ?? []].join(' ')).toContain('v-input--density-comfortable')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('the soft carrier rides the dialog flag, not the name: a custom skin extending pastel keeps it', async () => {
|
|
115
|
+
const card = await openDialog({ extends: 'pastel' })
|
|
116
|
+
|
|
117
|
+
expect(card.classList.contains('tiptapify-dialog--soft')).toBe(true)
|
|
118
|
+
expect(card.classList.contains('tiptapify-dialog--pastel')).toBe(false)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('an inline dialog flag alone drives the carrier and the divider', async () => {
|
|
122
|
+
const soft = await openDialog({ dialog: 'soft' })
|
|
123
|
+
expect(soft.classList.contains('tiptapify-dialog--soft')).toBe(true)
|
|
124
|
+
expect(soft.querySelector('.tiptapify-dialog-header')?.nextElementSibling?.classList.contains('v-divider')).toBe(false)
|
|
125
|
+
|
|
126
|
+
disposeDialogs()
|
|
127
|
+
|
|
128
|
+
const classic = await openDialog({ dialog: 'classic' })
|
|
129
|
+
expect(classic.classList.contains('tiptapify-dialog--soft')).toBe(false)
|
|
130
|
+
expect(classic.querySelector('.tiptapify-dialog-header')?.nextElementSibling?.classList.contains('v-divider')).toBe(true)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
<script lang="ts" setup>
|
|
2
2
|
|
|
3
3
|
import * as mdi from '@mdi/js'
|
|
4
|
-
import { inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
4
|
+
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
5
5
|
import { ComposerTranslation } from 'vue-i18n'
|
|
6
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
6
7
|
|
|
7
8
|
const props = defineProps({
|
|
8
9
|
module: String,
|
|
@@ -13,10 +14,27 @@ const props = defineProps({
|
|
|
13
14
|
|
|
14
15
|
const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
15
16
|
|
|
17
|
+
// The dialog is teleported to the overlay root, so the skin reaches it by injection,
|
|
18
|
+
// not by the editor's DOM
|
|
19
|
+
const { shape, tokens } = useTiptapifyConfig()
|
|
20
|
+
|
|
21
|
+
// The soft dialog shape drives every dialog site (carrier class, close variant, field defaults):
|
|
22
|
+
// one computed keeps the template readable (task-7 cleanup)
|
|
23
|
+
const dialogSoft = computed(() => shape.value.dialog === 'soft')
|
|
24
|
+
|
|
16
25
|
defineExpose({ open, close })
|
|
17
26
|
const emits = defineEmits(['closeDialog'])
|
|
18
27
|
|
|
19
28
|
const showDialog = ref<boolean>(false)
|
|
29
|
+
|
|
30
|
+
const field = { variant: 'outlined', density: 'comfortable' }
|
|
31
|
+
const fieldDefaults = {
|
|
32
|
+
VTextField: field,
|
|
33
|
+
VTextarea: field,
|
|
34
|
+
VSelect: field,
|
|
35
|
+
VAutocomplete: field,
|
|
36
|
+
VCombobox: field,
|
|
37
|
+
}
|
|
20
38
|
const movableHandler = ref(null)
|
|
21
39
|
|
|
22
40
|
function open() {
|
|
@@ -141,48 +159,146 @@ onBeforeUnmount(() => {
|
|
|
141
159
|
|
|
142
160
|
<template>
|
|
143
161
|
<VDialog v-model="showDialog" :max-width="maxWidth" :fullscreen="fullscreen" @click:outside="emitClose">
|
|
144
|
-
<VCard
|
|
162
|
+
<VCard
|
|
163
|
+
class="tiptapify-dialog"
|
|
164
|
+
:class="{ 'tiptapify-dialog--soft': dialogSoft, 'tiptapify-dialog--fullscreen': fullscreen }"
|
|
165
|
+
:style="tokens"
|
|
166
|
+
>
|
|
145
167
|
<VCardTitle
|
|
146
168
|
ref="movableHandler"
|
|
147
169
|
class="tiptapify-dialog-header"
|
|
148
170
|
:class="!fullscreen ? 'tiptapify-movable-handler' : ''"
|
|
149
171
|
style="user-select: none;"
|
|
150
172
|
>
|
|
151
|
-
<
|
|
173
|
+
<span class="tiptapify-dialog-title">
|
|
152
174
|
{{ title ?? t(`dialog.${module}.title`) }}
|
|
153
|
-
</
|
|
175
|
+
</span>
|
|
154
176
|
|
|
155
177
|
<VSpacer />
|
|
156
178
|
|
|
157
|
-
<VBtn
|
|
158
|
-
|
|
179
|
+
<VBtn
|
|
180
|
+
class="tiptapify-dialog-close"
|
|
181
|
+
:variant="dialogSoft ? 'text' : 'elevated'"
|
|
182
|
+
:elevation="dialogSoft ? undefined : 4"
|
|
183
|
+
:density="dialogSoft ? 'default' : 'compact'"
|
|
184
|
+
:size="dialogSoft ? '32' : undefined"
|
|
185
|
+
icon
|
|
186
|
+
@click="emitClose"
|
|
187
|
+
>
|
|
188
|
+
<VIcon :size="dialogSoft ? 20 : 'x-small'" :icon="`mdiSvg:${mdi.mdiClose}`" />
|
|
159
189
|
</VBtn>
|
|
160
190
|
</VCardTitle>
|
|
161
191
|
|
|
162
|
-
<VDivider />
|
|
192
|
+
<VDivider v-if="!dialogSoft" />
|
|
163
193
|
|
|
164
|
-
|
|
194
|
+
<!-- In the soft dialog shape, fields a dialog does not style itself get the light outlined look -->
|
|
195
|
+
<VDefaultsProvider :defaults="dialogSoft ? fieldDefaults : {}">
|
|
196
|
+
<slot name="content" />
|
|
165
197
|
|
|
166
|
-
|
|
198
|
+
<slot name="actions" />
|
|
199
|
+
</VDefaultsProvider>
|
|
167
200
|
</VCard>
|
|
168
201
|
</VDialog>
|
|
169
202
|
</template>
|
|
170
203
|
|
|
171
204
|
<style lang="scss" scoped>
|
|
205
|
+
/*
|
|
206
|
+
* Dialog skin rules: the base block reads the dialog tokens with
|
|
207
|
+
* the classic fallbacks; the soft block (carrier `tiptapify-dialog--soft`, teleported
|
|
208
|
+
* surfaces cannot ride the root's shape classes) reads the same tokens with the soft
|
|
209
|
+
* shape's base as fallback. The VCard itself carries the token `:style` (SkinScope step),
|
|
210
|
+
* so the tokens reach every rule here.
|
|
211
|
+
* The `!important`s stay exactly where Vuetify paints the pixel with an INLINE
|
|
212
|
+
* style (the card radius/shadow via the elevation/rounded composables, the content
|
|
213
|
+
* padding via density) — no selector can outrank an inline style, and these are the
|
|
214
|
+
* base-commit set, no additions. The header and actions padding keep the base-commit
|
|
215
|
+
* !importants dropped: Vuetify paints them as plain class rules, and the doubled
|
|
216
|
+
* scoped carrier outranks them (proven by the fixture gate).
|
|
217
|
+
*/
|
|
172
218
|
.tiptapify-dialog-header {
|
|
173
|
-
align-items: center;
|
|
174
219
|
display: flex;
|
|
220
|
+
align-items: center;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/* The classic title paints like a VLabel: a light, roomy label */
|
|
224
|
+
.tiptapify-dialog-title {
|
|
225
|
+
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
226
|
+
font-size: var(--tf-dialog-title-size, 1.25rem);
|
|
227
|
+
font-weight: var(--tf-dialog-title-weight, 400);
|
|
228
|
+
line-height: 1.75;
|
|
229
|
+
white-space: normal;
|
|
175
230
|
}
|
|
176
231
|
|
|
177
232
|
:deep(.v-overlay__content) {
|
|
178
233
|
position: fixed;
|
|
179
234
|
}
|
|
180
235
|
|
|
181
|
-
.tiptapify-movable-handler
|
|
236
|
+
.tiptapify-movable-handler {
|
|
182
237
|
cursor: move;
|
|
183
238
|
}
|
|
184
239
|
|
|
185
240
|
.tiptapify-dialog-close:hover {
|
|
186
241
|
cursor: pointer;
|
|
187
242
|
}
|
|
188
|
-
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Soft dialog shape: a light card in the spirit of Flux modals: generous padding,
|
|
246
|
+
* soft corners and shadow, no rules
|
|
247
|
+
*/
|
|
248
|
+
.tiptapify-dialog--soft {
|
|
249
|
+
border-radius: var(--tf-dialog-radius, 12px) !important;
|
|
250
|
+
box-shadow: var(--tf-dialog-shadow, 0 20px 48px rgba(0, 0, 0, 0.16), 0 2px 6px rgba(0, 0, 0, 0.06)) !important;
|
|
251
|
+
|
|
252
|
+
&.tiptapify-dialog--fullscreen {
|
|
253
|
+
border-radius: 0 !important;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
:deep(.v-card-text) {
|
|
257
|
+
padding: var(--tf-dialog-content-padding, 4px 20px) !important;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
:deep(.v-field--variant-outlined) {
|
|
261
|
+
border-radius: var(--tf-dialog-field-radius, 8px);
|
|
262
|
+
|
|
263
|
+
.v-field__outline__start {
|
|
264
|
+
border-start-start-radius: var(--tf-dialog-field-radius, 8px);
|
|
265
|
+
border-end-start-radius: var(--tf-dialog-field-radius, 8px);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.v-field__outline__end {
|
|
269
|
+
border-start-end-radius: var(--tf-dialog-field-radius, 8px);
|
|
270
|
+
border-end-end-radius: var(--tf-dialog-field-radius, 8px);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
:deep(.v-card-actions) {
|
|
275
|
+
gap: 8px;
|
|
276
|
+
padding: var(--tf-dialog-actions-padding, 16px 20px 20px);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
:deep(.v-card-actions .v-btn),
|
|
280
|
+
:deep(.tiptapify-dialog-actions .v-btn) {
|
|
281
|
+
min-height: var(--tf-dialog-action-btn-min-height, 38px);
|
|
282
|
+
padding-inline: 16px;
|
|
283
|
+
border-radius: var(--tf-dialog-action-btn-radius, 8px);
|
|
284
|
+
font-weight: 500;
|
|
285
|
+
letter-spacing: normal;
|
|
286
|
+
text-transform: none;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.tiptapify-dialog--soft .tiptapify-dialog-header {
|
|
291
|
+
padding: var(--tf-dialog-header-padding, 16px 12px 12px 20px);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.tiptapify-dialog--soft .tiptapify-dialog-title {
|
|
295
|
+
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
296
|
+
font-size: var(--tf-dialog-title-size, 1.125rem);
|
|
297
|
+
font-weight: var(--tf-dialog-title-weight, 600);
|
|
298
|
+
line-height: 1.4;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.tiptapify-dialog--soft .tiptapify-dialog-close {
|
|
302
|
+
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
303
|
+
}
|
|
304
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { editorExtensions } from '@tiptapify/components/editorExtensions'
|
|
3
|
+
|
|
4
|
+
describe('editor extensions', () => {
|
|
5
|
+
it('selects a link on click instead of opening it', () => {
|
|
6
|
+
const link = editorExtensions('', false, []).find(extension => extension.name === 'link')
|
|
7
|
+
|
|
8
|
+
expect(link?.options).toMatchObject({ openOnClick: false, enableClickSelection: true })
|
|
9
|
+
})
|
|
10
|
+
})
|
|
@@ -17,7 +17,6 @@ import { HardBreak } from '@tiptap/extension-hard-break'
|
|
|
17
17
|
import { HorizontalRule } from '@tiptap/extension-horizontal-rule'
|
|
18
18
|
import { Typography } from '@tiptap/extension-typography'
|
|
19
19
|
import { Highlight } from '@tiptap/extension-highlight'
|
|
20
|
-
import { Image } from '@tiptap/extension-image'
|
|
21
20
|
import { Youtube } from '@tiptap/extension-youtube'
|
|
22
21
|
import { Superscript } from '@tiptap/extension-superscript'
|
|
23
22
|
import { Subscript } from '@tiptap/extension-subscript'
|
|
@@ -29,7 +28,10 @@ import { InvisibleCharacters } from '@tiptap/extension-invisible-characters'
|
|
|
29
28
|
|
|
30
29
|
import { default as Iframe } from '@tiptapify/extensions/components/media/iframe'
|
|
31
30
|
import { BulletListCircle, BulletListSquare } from '@tiptapify/extensions/components/list/bullet'
|
|
31
|
+
import { ListStyle } from '@tiptapify/extensions/components/list/styles'
|
|
32
32
|
import { TiptapifyImage } from '@tiptapify/extensions/components/media/image'
|
|
33
|
+
import { ImageWithCaption } from '@tiptapify/extensions/components/media/image/caption'
|
|
34
|
+
import { ImageSlider } from '@tiptapify/extensions/components/media/image/slider'
|
|
33
35
|
import { TiptapifyLink } from '@tiptapify/extensions/components/media/link'
|
|
34
36
|
import { TiptapifyVideo } from '@tiptapify/extensions/components/media/video'
|
|
35
37
|
import CodeBlockComponent from '@tiptapify/extensions/components/CodeBlockComponent.vue'
|
|
@@ -52,7 +54,22 @@ const lowlight = createLowlight(common)
|
|
|
52
54
|
// register language example
|
|
53
55
|
// lowlight.register('ts', ts)
|
|
54
56
|
|
|
55
|
-
export
|
|
57
|
+
export interface TiptapifyImageOptions {
|
|
58
|
+
captionPlaceholder: string
|
|
59
|
+
// Images in a row from which the site shows a slider; 0 disables the slider grid
|
|
60
|
+
sliderMinImages: number
|
|
61
|
+
sliderLabel: string
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultImageOptions: TiptapifyImageOptions = { captionPlaceholder: '', sliderMinImages: 0, sliderLabel: '' }
|
|
65
|
+
|
|
66
|
+
export function editorExtensions (
|
|
67
|
+
placeholder: string,
|
|
68
|
+
slashCommands: SlashCommandsConfig,
|
|
69
|
+
customExtensions: toolbarSections,
|
|
70
|
+
limit: number | null = null,
|
|
71
|
+
imageOptions: TiptapifyImageOptions = defaultImageOptions,
|
|
72
|
+
) {
|
|
56
73
|
const characterCount = limit && limit > 0 ? CharacterCount.configure({ limit }) : CharacterCount
|
|
57
74
|
|
|
58
75
|
const extensions = [
|
|
@@ -65,7 +82,12 @@ export function editorExtensions (placeholder: string, slashCommands: SlashComma
|
|
|
65
82
|
Bold,
|
|
66
83
|
Italic,
|
|
67
84
|
Strike,
|
|
68
|
-
|
|
85
|
+
// A click on a link in the text selects it for editing instead of opening it
|
|
86
|
+
Link.configure({
|
|
87
|
+
openOnClick: false,
|
|
88
|
+
enableClickSelection: true,
|
|
89
|
+
defaultProtocol: 'https',
|
|
90
|
+
}),
|
|
69
91
|
Blockquote,
|
|
70
92
|
OrderedList,
|
|
71
93
|
BulletList,
|
|
@@ -83,11 +105,17 @@ export function editorExtensions (placeholder: string, slashCommands: SlashComma
|
|
|
83
105
|
openOnClick: false,
|
|
84
106
|
defaultProtocol: 'https'
|
|
85
107
|
}),
|
|
86
|
-
|
|
108
|
+
ImageWithCaption.configure({
|
|
87
109
|
resize: {
|
|
88
110
|
enabled: true,
|
|
89
111
|
alwaysPreserveAspectRatio: true,
|
|
112
|
+
directions: ['top', 'right', 'bottom', 'left', 'top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
|
90
113
|
},
|
|
114
|
+
captionPlaceholder: imageOptions.captionPlaceholder,
|
|
115
|
+
}),
|
|
116
|
+
ImageSlider.configure({
|
|
117
|
+
minImages: imageOptions.sliderMinImages,
|
|
118
|
+
label: imageOptions.sliderLabel,
|
|
91
119
|
}),
|
|
92
120
|
Youtube.configure({
|
|
93
121
|
controls: true,
|
|
@@ -117,7 +145,8 @@ export function editorExtensions (placeholder: string, slashCommands: SlashComma
|
|
|
117
145
|
visible: false,
|
|
118
146
|
}),
|
|
119
147
|
BulletListCircle,
|
|
120
|
-
BulletListSquare
|
|
148
|
+
BulletListSquare,
|
|
149
|
+
ListStyle,
|
|
121
150
|
]
|
|
122
151
|
|
|
123
152
|
if (slashCommands !== false) {
|
package/src/components/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Editor, useEditor } from '@tiptap/vue-3'
|
|
2
|
-
import { editorExtensions } from '@tiptapify/components/editorExtensions'
|
|
2
|
+
import { editorExtensions, TiptapifyImageOptions } from '@tiptapify/components/editorExtensions'
|
|
3
3
|
import { toolbarSections } from '@tiptapify/types/toolbarTypes'
|
|
4
4
|
import { SlashCommandsConfig } from '@tiptapify/types/slashCommandsTypes'
|
|
5
5
|
import { ShallowRef } from 'vue'
|
|
@@ -12,8 +12,9 @@ export function getTiptapEditor (
|
|
|
12
12
|
limit: number | null = null,
|
|
13
13
|
onUpdate: Function = () => {},
|
|
14
14
|
onCreate?: (editor: Editor) => void,
|
|
15
|
+
imageOptions?: TiptapifyImageOptions,
|
|
15
16
|
): ShallowRef<Editor | undefined> {
|
|
16
|
-
const extensions = editorExtensions(placeholder, slashCommands, customExtensions, limit)
|
|
17
|
+
const extensions = editorExtensions(placeholder, slashCommands, customExtensions, limit, imageOptions)
|
|
17
18
|
|
|
18
19
|
return useEditor({
|
|
19
20
|
content,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { createSSRApp, h, provide, ref } from 'vue'
|
|
2
|
+
import { computed, createSSRApp, h, provide, ref } from 'vue'
|
|
3
3
|
import { renderToString } from 'vue/server-renderer'
|
|
4
4
|
import { createVuetify } from 'vuetify'
|
|
5
5
|
import BoldButton from '@tiptapify/extensions/components/format/bold/Button.vue'
|
|
6
6
|
import { TIPTAPIFY_CONFIG_KEY, useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
7
|
+
import { resolveSkin } from '@tiptapify/skins/registry'
|
|
7
8
|
|
|
8
9
|
const editor = {
|
|
9
10
|
isActive: () => false,
|
|
@@ -13,10 +14,16 @@ const editor = {
|
|
|
13
14
|
|
|
14
15
|
const Consumer = {
|
|
15
16
|
setup () {
|
|
16
|
-
const { variantBtn, variantField } = useTiptapifyConfig()
|
|
17
|
+
const { variantBtn, variantField, skin, skinName, tokens, shape } = useTiptapifyConfig()
|
|
17
18
|
return () => h('span', [
|
|
18
19
|
h('span', { id: 'variant-btn' }, variantBtn.value),
|
|
19
20
|
h('span', { id: 'variant-field' }, variantField.value),
|
|
21
|
+
h('span', { id: 'skin' }, typeof skin.value === 'string' ? skin.value : ''),
|
|
22
|
+
h('span', { id: 'skin-name' }, skinName.value),
|
|
23
|
+
h('span', { id: 'token-count' }, String(Object.keys(tokens.value).length)),
|
|
24
|
+
h('span', { id: 'token-button-radius' }, tokens.value['--tf-button-radius'] ?? ''),
|
|
25
|
+
h('span', { id: 'shape-toolbar' }, shape.value.toolbar),
|
|
26
|
+
h('span', { id: 'shape-buttons' }, shape.value.buttons),
|
|
20
27
|
])
|
|
21
28
|
},
|
|
22
29
|
}
|
|
@@ -25,9 +32,14 @@ function render (root: () => any, withConfig: boolean) {
|
|
|
25
32
|
const app = createSSRApp({
|
|
26
33
|
setup () {
|
|
27
34
|
if (withConfig) {
|
|
35
|
+
const pastel = resolveSkin('pastel')
|
|
28
36
|
provide(TIPTAPIFY_CONFIG_KEY, {
|
|
29
37
|
variantBtn: ref('tonal'),
|
|
30
38
|
variantField: ref('outlined'),
|
|
39
|
+
skin: ref('pastel'),
|
|
40
|
+
skinName: computed(() => pastel.name),
|
|
41
|
+
tokens: computed(() => pastel.tokens),
|
|
42
|
+
shape: computed(() => pastel.shape),
|
|
31
43
|
})
|
|
32
44
|
}
|
|
33
45
|
provide('tiptapifyEditor', ref(editor))
|
|
@@ -46,6 +58,11 @@ describe('useTiptapifyConfig', () => {
|
|
|
46
58
|
|
|
47
59
|
expect(html).toContain('<span id="variant-btn">tonal</span>')
|
|
48
60
|
expect(html).toContain('<span id="variant-field">outlined</span>')
|
|
61
|
+
expect(html).toContain('<span id="skin">pastel</span>')
|
|
62
|
+
expect(html).toContain('<span id="skin-name">pastel</span>')
|
|
63
|
+
expect(html).toContain('<span id="token-button-radius">6px</span>')
|
|
64
|
+
expect(html).toContain('<span id="shape-toolbar">flat</span>')
|
|
65
|
+
expect(html).toContain('<span id="shape-buttons">quiet</span>')
|
|
49
66
|
})
|
|
50
67
|
|
|
51
68
|
it('falls back to defaults when no provider is present', async () => {
|
|
@@ -53,6 +70,12 @@ describe('useTiptapifyConfig', () => {
|
|
|
53
70
|
|
|
54
71
|
expect(html).toContain('<span id="variant-btn">flat</span>')
|
|
55
72
|
expect(html).toContain('<span id="variant-field">solo</span>')
|
|
73
|
+
expect(html).toContain('<span id="skin">default</span>')
|
|
74
|
+
// The fallback resolves the default skin: classic shape, no emitted tokens
|
|
75
|
+
expect(html).toContain('<span id="skin-name">default</span>')
|
|
76
|
+
expect(html).toContain('<span id="token-count">0</span>')
|
|
77
|
+
expect(html).toContain('<span id="shape-toolbar">raised</span>')
|
|
78
|
+
expect(html).toContain('<span id="shape-buttons">vuetify</span>')
|
|
56
79
|
})
|
|
57
80
|
|
|
58
81
|
it('is consumed by toolbar extension buttons', async () => {
|
|
@@ -1,17 +1,36 @@
|
|
|
1
|
-
import { computed, inject, InjectionKey, Ref } from 'vue'
|
|
1
|
+
import { computed, ComputedRef, inject, InjectionKey, Ref } from 'vue'
|
|
2
2
|
import defaults from '@tiptapify/constants/defaults'
|
|
3
|
-
import { variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
|
|
3
|
+
import { TiptapifyShape, TiptapifySkinInput, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
|
|
4
|
+
// Importing the builtins registers them on load, so resolution works without consumer setup
|
|
5
|
+
import '@tiptapify/skins/builtins'
|
|
6
|
+
import { resolveSkin } from '@tiptapify/skins/registry'
|
|
4
7
|
|
|
5
8
|
export interface TiptapifyConfig {
|
|
6
9
|
variantBtn: Ref<variantBtnTypes>
|
|
7
10
|
variantField: Ref<variantFieldTypes>
|
|
11
|
+
// the raw `skin` prop: a registered name (string) or an inline skin object
|
|
12
|
+
skin: Ref<TiptapifySkinInput>
|
|
13
|
+
// the resolved skin name (inline objects without a name resolve to 'inline')
|
|
14
|
+
skinName: ComputedRef<string>
|
|
15
|
+
// css variable name -> value; only overrides, the css fallbacks carry the base
|
|
16
|
+
tokens: ComputedRef<Record<string, string>>
|
|
17
|
+
shape: ComputedRef<Required<TiptapifyShape>>
|
|
8
18
|
}
|
|
9
19
|
|
|
10
20
|
export const TIPTAPIFY_CONFIG_KEY: InjectionKey<TiptapifyConfig> = Symbol('tiptapifyConfig')
|
|
11
21
|
|
|
22
|
+
// Components outside an editor (docs playground) resolve the default skin:
|
|
23
|
+
// classic shape, and no emitted tokens — the css fallbacks are the base values.
|
|
24
|
+
// Resolved once at module load: the fallback never changes at runtime
|
|
25
|
+
const defaultSkin = resolveSkin(defaults.skin)
|
|
26
|
+
|
|
12
27
|
export function useTiptapifyConfig(): TiptapifyConfig {
|
|
13
28
|
return inject(TIPTAPIFY_CONFIG_KEY, {
|
|
14
29
|
variantBtn: computed(() => defaults.variantBtn),
|
|
15
30
|
variantField: computed(() => defaults.variantField),
|
|
31
|
+
skin: computed(() => defaults.skin),
|
|
32
|
+
skinName: computed(() => defaultSkin.name),
|
|
33
|
+
tokens: computed(() => defaultSkin.tokens),
|
|
34
|
+
shape: computed(() => defaultSkin.shape),
|
|
16
35
|
})
|
|
17
36
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
|
|
1
|
+
import { TiptapifySkin, variantBtnTypes, variantFieldTypes } from '@tiptapify/types/editor'
|
|
2
2
|
|
|
3
3
|
export default {
|
|
4
4
|
variantBtn: 'flat' as variantBtnTypes,
|
|
5
5
|
variantField: 'solo' as variantFieldTypes,
|
|
6
|
+
skin: 'default' as TiptapifySkin,
|
|
6
7
|
}
|
|
@@ -26,7 +26,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
|
26
26
|
@click="editor.chain().focus().redo().run()"
|
|
27
27
|
>
|
|
28
28
|
<Tooltip :label="t('action.redo')" hotkey="Mod-Shift-z" />
|
|
29
|
-
<BtnIcon :icon="`mdiSvg:${mdi.
|
|
29
|
+
<BtnIcon :icon="`mdiSvg:${mdi.mdiRedoVariant}`" />
|
|
30
30
|
</VBtn>
|
|
31
31
|
</template>
|
|
32
32
|
|
|
@@ -26,7 +26,7 @@ const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
|
26
26
|
@click="editor.chain().focus().undo().run()"
|
|
27
27
|
>
|
|
28
28
|
<Tooltip :label="t('action.undo')" hotkey="Mod-z" />
|
|
29
|
-
<BtnIcon :icon="`mdiSvg:${mdi.
|
|
29
|
+
<BtnIcon :icon="`mdiSvg:${mdi.mdiUndoVariant}`" />
|
|
30
30
|
</VBtn>
|
|
31
31
|
</template>
|
|
32
32
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts" setup>
|
|
2
|
+
|
|
3
|
+
import * as mdi from '@mdi/js'
|
|
4
|
+
import { Editor } from '@tiptap/vue-3'
|
|
5
|
+
import BtnIcon from '@tiptapify/components/UI/BtnIcon.vue'
|
|
6
|
+
import SkinScope from '@tiptapify/components/SkinScope.vue'
|
|
7
|
+
import Tooltip from '@tiptapify/components/UI/Tooltip.vue'
|
|
8
|
+
import { useTiptapifyConfig } from '@tiptapify/composables/useTiptapifyConfig'
|
|
9
|
+
import { menuContentClass } from '@tiptapify/skins/menu'
|
|
10
|
+
import { inject, PropType, Ref } from 'vue'
|
|
11
|
+
|
|
12
|
+
import { ComposerTranslation } from 'vue-i18n'
|
|
13
|
+
|
|
14
|
+
type Alignment = 'left' | 'center' | 'right' | 'justify'
|
|
15
|
+
|
|
16
|
+
const props = defineProps({
|
|
17
|
+
alignments: { type: Array as PropType<Alignment[]>, default: () => ['left', 'center', 'right', 'justify'] },
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const { variantBtn, shape } = useTiptapifyConfig()
|
|
21
|
+
|
|
22
|
+
const editor = inject('tiptapifyEditor') as Ref<Editor>
|
|
23
|
+
|
|
24
|
+
const { t } = inject('tiptapifyI18n') as { t: ComposerTranslation }
|
|
25
|
+
|
|
26
|
+
const alignmentOptions: Record<Alignment, { icon: string, hotkey: string }> = {
|
|
27
|
+
left: { icon: mdi.mdiFormatAlignLeft, hotkey: 'Mod-Shift-l' },
|
|
28
|
+
center: { icon: mdi.mdiFormatAlignCenter, hotkey: 'Mod-Shift-e' },
|
|
29
|
+
right: { icon: mdi.mdiFormatAlignRight, hotkey: 'Mod-Shift-r' },
|
|
30
|
+
justify: { icon: mdi.mdiFormatAlignJustify, hotkey: 'Mod-Shift-j' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A function, not a computed: `isActive` reads the editor state, which Vue does not track
|
|
34
|
+
function currentAlignmentIcon(): string {
|
|
35
|
+
const active = props.alignments.find(alignment => editor.value.isActive({ textAlign: alignment }))
|
|
36
|
+
|
|
37
|
+
return alignmentOptions[active ?? props.alignments[0] ?? 'left'].icon
|
|
38
|
+
}
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<template>
|
|
42
|
+
<VBtn
|
|
43
|
+
:id="`tiptapify-align-button-${editor.instanceId}`"
|
|
44
|
+
class="tiptapify-dropdown-button"
|
|
45
|
+
:variant="variantBtn"
|
|
46
|
+
height="32"
|
|
47
|
+
>
|
|
48
|
+
<Tooltip :label="t('alignment')" />
|
|
49
|
+
<BtnIcon :icon="`mdiSvg:${currentAlignmentIcon()}`" />
|
|
50
|
+
<BtnIcon :icon="`mdiSvg:${mdi.mdiChevronDown}`" class="tiptapify-dropdown-button__chevron" />
|
|
51
|
+
</VBtn>
|
|
52
|
+
|
|
53
|
+
<VMenu :activator="`#tiptapify-align-button-${editor.instanceId}`" :content-class="menuContentClass(shape.menu === 'soft')">
|
|
54
|
+
<SkinScope>
|
|
55
|
+
<VList density="compact">
|
|
56
|
+
<VListItem
|
|
57
|
+
v-for="alignment in alignments"
|
|
58
|
+
:key="alignment"
|
|
59
|
+
link
|
|
60
|
+
:active="editor.isActive({ textAlign: alignment })"
|
|
61
|
+
@click="editor.chain().focus().toggleTextAlign(alignment).run()"
|
|
62
|
+
>
|
|
63
|
+
<Tooltip :label="t(`alignments.${alignment}`)" :hotkey="alignmentOptions[alignment].hotkey" />
|
|
64
|
+
<template #prepend>
|
|
65
|
+
<BtnIcon :icon="`mdiSvg:${alignmentOptions[alignment].icon}`" />
|
|
66
|
+
</template>
|
|
67
|
+
<VListItemTitle>{{ t(`alignments.${alignment}`) }}</VListItemTitle>
|
|
68
|
+
</VListItem>
|
|
69
|
+
</VList>
|
|
70
|
+
</SkinScope>
|
|
71
|
+
</VMenu>
|
|
72
|
+
</template>
|